# Blip Threads in your phone app

For a program that already shows Blip Threads in its web app (INSTALL.md, Step 4C) and wants the same two screens in
its own iPhone and Android app, with a notification when a message arrives.

**Read this first: what this guide is.** It is a recipe. Blip's side of it is built and checked in desktop browsers at
phone sizes and in the web view engine on a Mac. **Nobody has run it on a real phone yet.** The last section lists
exactly what still needs one. Follow it expecting to test and fix on a device, and tell Blip what you find.

How it fits together:

- **Your app is the app.** It is your web app inside a Capacitor shell (version 8 here), published under your name.
  Blip has no phone app of its own in this.
- **The screens are the same two.** `BlipThread` and `BlipThreadInbox` (or `threads.js`) work in the shell once Blip
  knows the shell's address (Step 1).
- **You send the notifications, not Blip.** A phone's push token belongs to the app that asked for it, so only your
  server can push into your app. Blip tells your server about each new message with the `thread.message` webhook, and
  your server sends the push through APNs (iPhone) and FCM (Android) (Steps 5 and 6).
- **A phone records camera and voice, not its screen.** A web view on a phone has no screen sharing, so the recorder
  offers the camera only. Screen recordings are made on a computer.

You do Steps 1 to 8 in order. Each step says what to check before the next.

---

## Step 1. Tell Blip your shell's address

Blip only lets the screens be framed by an address your app has listed. In a browser that is your site
(`https://app.example.com`). Inside a phone app your pages are not on a site. The web view gives them these addresses:

| Platform | Address (the default) | Set by |
|---|---|---|
| iPhone, iPad | `capacitor://localhost` | `server.iosScheme` and `server.hostname` in `capacitor.config.ts` |
| Android | `https://localhost` | `server.androidScheme` and `server.hostname` |

In Blip, **Settings, Developers, your app, Phone app addresses**, add the ones you ship, one a line:

```
capacitor://localhost
https://localhost
```

Then make the session with the address the page is really on. Send it from the page, and let your server accept only
the ones you expect:

```ts
// The page (runs in the browser and in the shell): where am I?
const res = await fetch(`${API}/api/blip/thread-session`, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${yourOwnSessionToken}` },
  body: JSON.stringify({ origin: window.location.origin }),
});
```

```ts
// Your server: app/api/blip/thread-session/route.ts
import { createThreadSession, requireBlip } from "@/lib/blip";

const MAY_HOLD_THE_SCREENS = new Set(["https://app.example.com", "capacitor://localhost", "https://localhost"]);

export async function POST(req: Request) {
  const user = await getCurrentUser(req); // YOUR app's own auth
  if (!user) return Response.json({ error: "Sign in first." }, { status: 401 });
  const { origin } = (await req.json()) as { origin?: string };
  if (!origin || !MAY_HOLD_THE_SCREENS.has(origin)) return Response.json({ error: "Unknown address." }, { status: 400 });
  const r = await createThreadSession(requireBlip(), {
    user: { id: String(user.id), name: user.name, role: user.isCoach ? "coach" : "student" },
    origin,
  });
  if (!r.ok) return Response.json({ error: r.error }, { status: r.status ?? 502 });
  return Response.json({ thread_url: r.data.thread_url, inbox_url: r.data.inbox_url });
}
```

The rules Blip applies, so you know what to expect:

- The address must match one on your list **letter for letter**. No wildcard, no port, no path, lowercase only.
- Only these shapes are taken: `capacitor://<host>`, `ionic://<host>`, `https://localhost`, `http://localhost`.
- A phone app address opens Threads and nothing else. `localhost` in your app's **Sites** list (for trying things on
  your own computer) does not let a phone app in, and a phone app address does not let a website in.
- If you set `server.hostname` to your own domain on Android (`https://app.example.com`), that is an ordinary site:
  put the domain in **Sites**, not here.
- If your shell loads your live site (`server.url`) instead of files packed in the app, the pages are on your site and
  you need nothing from this step.

**What this protects and what it can't.** In a browser, Blip's rule means only your site can frame a person's
conversation. Inside phone apps that promise is weaker, and you should know how: `capacitor://localhost` is the
address of **every** app built on the same shell, and nothing proves which app is asking, the way a domain and its
certificate prove a website. So with a phone app address listed:

- Kept: no website can frame the screens (a browser page is never on `capacitor://`), a session still belongs to one
  person for two hours, and nobody gets a session without your server and your API key.
- Lost: Blip can no longer tell your phone app from another phone app. If a person's session address leaked, another
  phone app could show it. (A phone app that has the address could open it directly anyway; framing rules never
  stopped that.) So the session address is the thing to protect: make it on your server for the signed-in person,
  never log it, never put it in a link or a notification.

**Check:** from the app, the call above answers `201`. A `403` saying the phone app address isn't on the list means
the address in Blip doesn't match `window.location.origin` exactly.

---

## Step 2. The shell

```ts
// capacitor.config.ts
import type { CapacitorConfig } from "@capacitor/cli";

const config: CapacitorConfig = {
  appId: "com.example.program", // your bundle id; APNS_TOPIC in Step 6 must equal the iPhone one exactly
  appName: "Your Program",
  webDir: "dist",
  // Leave `server` out to keep the default addresses from Step 1.
  ios: {
    // 'never': the web view fills the screen and YOUR css pads for the notch and the home bar (Step 3).
    // With 'automatic' the system pads too and the gaps are counted twice.
    contentInset: "never",
  },
  plugins: {
    // The system moves nothing when the keyboard opens; you shrink the one scrolling area yourself.
    Keyboard: { resize: "none", resizeOnFullScreen: true },
  },
};
export default config;
```

Packages: `@capacitor/core`, `@capacitor/ios`, `@capacitor/android`, `@capacitor/app`, `@capacitor/preferences`,
`@capacitor/push-notifications`, `@capacitor/keyboard`.

**iPhone: `ios/App/App/Info.plist`.** Without these the app is closed by the system the moment the recorder asks.
Write what your app does with each, in your own words: App Review reads them.

```xml
<key>NSCameraUsageDescription</key>
<string>Your Program uses your camera when you record a video message for your coach.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Your Program uses your microphone when you record a video or voice message for your coach.</string>
```

In Xcode, Signing and Capabilities: add **Push Notifications**, and **Associated Domains** with
`applinks:app.example.com` (Step 7). In `AppDelegate.swift`, hand the token to the push plugin:

```swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
```

**Android: `android/app/src/main/AndroidManifest.xml`.**

```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```

`MODIFY_AUDIO_SETTINGS` is needed for a web view to use the microphone. `POST_NOTIFICATIONS` is asked for on Android
13 and later. For FCM, add your `google-services.json` to `android/app/`.

**Check:** the app opens, shows your web app, and `window.location.origin` is what you listed in Step 1.

---

## Step 3. Your pages, on a phone

Blip's two screens already follow these rules inside their frame. Your own pages around them have to as well.

- **Every input is 16px or larger on touch screens.** An iPhone zooms the whole page when a smaller one is tapped and
  never zooms back, which looks like every screen is broken at once. Put this in your base stylesheet:

  ```css
  @media (pointer: coarse) {
    input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea { font-size: 16px; }
  }
  ```

- **You pad for the notch and the home bar; the frame doesn't.** A frame can't see the phone's edges. Give the page
  `viewport-fit=cover`, pad your own header and the box that holds the screen, and let the screen fill that box:

  ```html
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
  ```
  ```css
  .app-header { padding-top: env(safe-area-inset-top); }
  .threads-box { height: 100%; padding-bottom: env(safe-area-inset-bottom); }
  ```
  ```tsx
  <div className="threads-box"><BlipThread url={s.thread_url} height="100%" onExpired={load} /></div>
  ```

- **When the keyboard opens, shrink the box, don't move the page.** With `Keyboard.resize: "none"`, listen for the
  keyboard's height and take it off the box that owns the height:

  ```ts
  import { Keyboard } from "@capacitor/keyboard";
  Keyboard.addListener("keyboardWillShow", (e) => document.documentElement.style.setProperty("--kb", `${e.keyboardHeight}px`));
  Keyboard.addListener("keyboardWillHide", () => document.documentElement.style.setProperty("--kb", "0px"));
  // css: .threads-box { height: calc(100% - var(--kb, 0px)); }
  ```

- **Nothing that only appears on hover.** A phone has no hover. Anything a person must tap is always visible and at
  least 44px tall.

---

## Step 4. Keep your own sign-in in Preferences

An iPhone can empty a web view's storage when it is short of space, which signs people out at random. Keep **your
app's own** sign-in token in the phone's settings store instead. Blip's session is not kept anywhere: make a new one
each time the screen opens.

```ts
import { Capacitor } from "@capacitor/core";
import { Preferences } from "@capacitor/preferences";

/** The same three calls as localStorage, so most sign-in libraries take it as their storage. */
export const authStorage = Capacitor.isNativePlatform()
  ? {
      getItem: async (key: string) => (await Preferences.get({ key })).value,
      setItem: async (key: string, value: string) => void (await Preferences.set({ key, value })),
      removeItem: async (key: string) => void (await Preferences.remove({ key })),
    }
  : undefined; // a browser keeps its default
```

Cookies set by your server are also unreliable in a shell (the page is not on your site). Send your sign-in as an
`Authorization` header, as the Step 1 call does.

---

## Step 5. Register for push and keep the tokens

A table on your server (plain SQL; adapt to your database):

```sql
create table push_tokens (
  token      text primary key,          -- the APNs or FCM token
  user_id    text not null,             -- YOUR id for the person: the same id you give Blip
  kind       text not null check (kind in ('apns', 'fcm')),
  created_at timestamptz not null default now()
);
create index push_tokens_user on push_tokens (user_id);
```

In the app, after sign-in:

```ts
import { Capacitor } from "@capacitor/core";

/** Ask once, register, keep the token, and open the right conversation when a notification is tapped. */
export async function startPush(saveToken: (t: { token: string; kind: "apns" | "fcm" }) => Promise<void>, open: (path: string) => void) {
  if (!Capacitor.isNativePlatform()) return;
  const { PushNotifications } = await import("@capacitor/push-notifications");

  let perm = await PushNotifications.checkPermissions();
  if (perm.receive === "prompt") perm = await PushNotifications.requestPermissions();
  if (perm.receive !== "granted") return; // they said no: the app still works, they just aren't told

  await PushNotifications.removeAllListeners();
  // On an iPhone the token is a raw APNs token; on Android it is an FCM token.
  await PushNotifications.addListener("registration", (t) => void saveToken({ token: t.value, kind: Capacitor.getPlatform() === "ios" ? "apns" : "fcm" }));
  await PushNotifications.addListener("pushNotificationActionPerformed", (a) => {
    const path = (a.notification.data as { path?: string } | undefined)?.path;
    // Only a path inside your own app. Never open whatever a notification carries.
    if (typeof path === "string" && path.startsWith("/") && !path.startsWith("//")) open(path);
  });
  await PushNotifications.register();
}
```

`saveToken` posts to a route of yours that upserts the row for the signed-in person. On sign-out, delete that
phone's row. **Ask for notifications when it means something** (after their first question is sent, "Want to know when
your coach answers?"), not on first launch.

---

## Step 6. The webhook that sends the push

In Blip, Settings, Developers, your app: tick **`thread.message`**. Blip then POSTs every new message to your webhook
address, signed. This route checks the signature with the plugin, works out who should hear about it, and pushes.

Who is told:

- **A student wrote:** the coach or assistant the conversation is given to (`w.thread.assigned_to`); if it is
  nobody's yet, everyone on your team.
- **Your team (or the AI) wrote:** the student (`w.thread.student`).
- **In a group** (`w.thread.kind === "group"`): the students in it (`w.members.ids`) and your team. When
  `w.members.complete` is false the group has more than 200 students: ask for them all with `listThreadGroupMembers`.
- Never the person who wrote it, and nothing for notes Blip adds (`kind: "event"`).

```ts
// app/api/blip/webhook/route.ts
import { readWebhook } from "@/lib/blip";
import { sendApns, sendFcm } from "@/lib/push";

export async function POST(req: Request) {
  const raw = await req.text(); // the raw body, before any JSON parsing, or the signature won't match
  const w = readWebhook(process.env.BLIP_WEBHOOK_SECRET!, raw, req.headers);
  if (!w) return new Response("bad signature", { status: 401 });
  if (await alreadyHandled(w.delivery_id)) return Response.json({ ok: true }); // the same delivery can come twice

  if (w.type === "thread.message" && w.message.kind !== "event" && w.message.role !== "system") {
    const fromStudent = w.message.role === "student";
    const to: string[] = w.thread.kind === "group"
      ? [...(w.members?.ids ?? []), ...(await yourTeamIds())] // everyone in the group, and your team
      : fromStudent
      ? w.thread.assigned_to
        ? [w.thread.assigned_to.id]
        : await yourTeamIds() // YOUR ids for every coach and assistant
      : w.thread.student
        ? [w.thread.student.id]
        : [];
    const ids = to.filter((id) => id !== w.message.from?.id);

    const who = w.message.role === "ai" ? "An answer for you" : (w.message.from?.name ?? "New message");
    const said =
      w.message.kind === "video" ? "Sent a video" : w.message.kind === "voice" ? "Sent a voice note" : (w.message.text ?? "").slice(0, 140);
    // The path opens YOUR page; your page makes a fresh session and opens this conversation (Step 7).
    // Never put a Blip session address in a notification.
    const push = { title: who, body: said, path: `/coaching?thread=${w.thread.id}`, tag: w.thread.id };

    const rows = await tokensFor(ids); // select token, kind from push_tokens where user_id = any($1)
    const dead = [
      ...(await sendApns(rows.filter((r) => r.kind === "apns").map((r) => r.token), push)),
      ...(await sendFcm(rows.filter((r) => r.kind === "fcm").map((r) => r.token), push)),
    ];
    if (dead.length) await deleteTokens(dead); // the phone removed the app or turned notifications off
  }
  return Response.json({ ok: true });
}
```

Answer `2xx` within 10 seconds. If you have many people to tell, save the event and push from a background job.
Think about what the notification shows on a locked screen: a student's question may be private. `body` can be just
"New message" if you prefer.

The senders (`lib/push.ts`, Node 18 or later, `npm i jose`). Both do nothing until their settings exist, and both
return the tokens that are dead so you can delete them.

```ts
import { connect } from "node:http2";
import { importPKCS8, SignJWT } from "jose";

export interface Push { title: string; body: string; path: string; tag?: string }

// ---- Android: FCM (HTTP v1) ------------------------------------------------------------------------------------------
// FCM_SERVICE_ACCOUNT: the service account's JSON, on one line.
let fcmToken: { value: string; exp: number } | null = null;

async function fcmAccess(sa: { client_email: string; private_key: string }): Promise<string> {
  if (fcmToken && fcmToken.exp > Date.now() + 60_000) return fcmToken.value;
  const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/firebase.messaging" })
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuer(sa.client_email)
    .setAudience("https://oauth2.googleapis.com/token")
    .setIssuedAt()
    .setExpirationTime("1h")
    .sign(await importPKCS8(sa.private_key, "RS256"));
  const res = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
  });
  const out = (await res.json()) as { access_token?: string; expires_in?: number };
  if (!res.ok || !out.access_token) throw new Error("FCM sign-in failed");
  fcmToken = { value: out.access_token, exp: Date.now() + (out.expires_in ?? 3600) * 1000 };
  return fcmToken.value;
}

export async function sendFcm(tokens: string[], p: Push): Promise<string[]> {
  const raw = process.env.FCM_SERVICE_ACCOUNT;
  if (!raw || !tokens.length) return [];
  const sa = JSON.parse(raw) as { project_id: string; client_email: string; private_key: string };
  const access = await fcmAccess(sa);
  const dead: string[] = [];
  for (const token of tokens) {
    const res = await fetch(`https://fcm.googleapis.com/v1/projects/${sa.project_id}/messages:send`, {
      method: "POST",
      headers: { authorization: `Bearer ${access}`, "content-type": "application/json" },
      body: JSON.stringify({
        message: {
          token,
          notification: { title: p.title, body: p.body },
          data: { path: p.path },
          android: { priority: "HIGH", ...(p.tag ? { collapse_key: p.tag } : {}) },
        },
      }),
    });
    if (res.ok) continue;
    const out = (await res.json().catch(() => ({}))) as { error?: { status?: string; details?: { errorCode?: string }[] } };
    const code = out.error?.details?.[0]?.errorCode ?? out.error?.status;
    if (res.status === 404 || code === "UNREGISTERED") dead.push(token);
    else console.error("fcm", res.status, code);
  }
  return dead;
}

// ---- iPhone: APNs (HTTP/2, token sign-in) ----------------------------------------------------------------------------
// APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY (the .p8 file's contents), APNS_TOPIC (your iPhone bundle id, exactly:
// APNs refuses a topic that doesn't match), APNS_HOST (api.push.apple.com; api.sandbox.push.apple.com for a build run
// from Xcode).
let apnsJwt: { value: string; exp: number } | null = null;

async function apnsBearer(): Promise<string> {
  // APNs refuses a token older than an hour, and refuses new ones made too often: keep one for 45 minutes.
  if (apnsJwt && apnsJwt.exp > Date.now()) return apnsJwt.value;
  const key = await importPKCS8(process.env.APNS_PRIVATE_KEY!.replace(/\\n/g, "\n"), "ES256");
  const value = await new SignJWT({}).setProtectedHeader({ alg: "ES256", kid: process.env.APNS_KEY_ID! }).setIssuer(process.env.APNS_TEAM_ID!).setIssuedAt().sign(key);
  apnsJwt = { value, exp: Date.now() + 45 * 60_000 };
  return value;
}

export async function sendApns(tokens: string[], p: Push): Promise<string[]> {
  const { APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY, APNS_TOPIC } = process.env;
  if (!APNS_KEY_ID || !APNS_TEAM_ID || !APNS_PRIVATE_KEY || !APNS_TOPIC || !tokens.length) return [];
  const bearer = await apnsBearer();
  // APNs only speaks HTTP/2. Node's fetch doesn't, so this uses node:http2. (On Deno or Bun, fetch works as it is.)
  const client = connect(`https://${process.env.APNS_HOST ?? "api.push.apple.com"}`);
  client.on("error", (e) => console.error("apns", e.message));
  const dead: string[] = [];
  try {
    for (const token of tokens) {
      const { status, reason } = await new Promise<{ status: number; reason?: string }>((resolve) => {
        const req = client.request({
          ":method": "POST",
          ":path": `/3/device/${token}`,
          authorization: `bearer ${bearer}`,
          "apns-topic": APNS_TOPIC,
          "apns-push-type": "alert",
          ...(p.tag ? { "apns-collapse-id": p.tag.slice(0, 63) } : {}),
        });
        let status = 0;
        let text = "";
        req.on("response", (h) => (status = Number(h[":status"])));
        req.on("data", (d) => (text += d));
        req.on("end", () => {
          let reason: string | undefined;
          try { reason = text ? (JSON.parse(text) as { reason?: string }).reason : undefined; } catch { /* not JSON */ }
          resolve({ status, reason });
        });
        req.on("error", () => resolve({ status: 0 }));
        req.end(JSON.stringify({ aps: { alert: { title: p.title, body: p.body }, sound: "default", "thread-id": p.tag }, path: p.path }));
      });
      if (status === 200) continue;
      if (status === 410 || reason === "BadDeviceToken" || reason === "Unregistered") dead.push(token);
      else console.error("apns", status, reason);
    }
  } finally {
    client.close();
  }
  return dead;
}
```

**Check, before any phone:** send yourself a test webhook (`testWebhook(requireBlip())`, INSTALL.md Step 6) and see
`ok: true`; then post a message as a student with the API and see your route pick the right people. The push itself
can only be checked on a phone.

---

## Step 7. A tapped notification opens the conversation

Two ways in, both ending on your own page:

**From the notification** (Step 5's listener): `open("/coaching?thread=<id>")`.

**From a link** (an email, a text): Universal Links on iPhone and App Links on Android make
`https://app.example.com/coaching?thread=<id>` open the app instead of the browser.

- iPhone: serve `https://app.example.com/.well-known/apple-app-site-association` (no file ending, `application/json`):
  ```json
  { "applinks": { "details": [{ "appIDs": ["TEAMID.com.example.program"], "components": [{ "/": "/coaching*" }] }] } }
  ```
- Android: an intent filter on the main activity, and `https://app.example.com/.well-known/assetlinks.json` with your
  signing key's SHA-256:
  ```xml
  <intent-filter android:autoVerify="true">
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="https" android:host="app.example.com" android:pathPrefix="/coaching" />
  </intent-filter>
  ```
- In the app:
  ```ts
  import { App } from "@capacitor/app";
  App.addListener("appUrlOpen", ({ url }) => {
    const u = new URL(url);
    if (u.pathname.startsWith("/coaching")) navigate(u.pathname + u.search); // only the paths you mean to open
  });
  ```

**Your page opens that conversation** by adding `thread` to the address your server gave it:

```tsx
const thread = new URLSearchParams(location.search).get("thread");
const withThread = (url: string) => (thread ? `${url}&thread=${encodeURIComponent(thread)}` : url);

return coach
  ? <BlipThreadInbox url={withThread(s.inbox_url)} height="100%" onExpired={load} />
  : <BlipThread url={withThread(s.thread_url)} height="100%" onExpired={load} />;
```

`thread` is the `thread.id` from the webhook. Blip opens it only if the signed-in person may read it; otherwise the
screen opens as usual (a student's own conversation, a coach's inbox). The person must be signed in to your app first:
if they aren't, send them to sign in and then back to the same path.

---

## Step 8. Before you send it to the stores

Lessons from a real submission of an app built this way.

- **No prices and no checkout inside the iPhone app** (App Store rule 3.1.1), unless you sell through Apple's own
  purchases. Blip's screens show no prices, plans or upgrade buttons: when a student is out of minutes they say only
  that video isn't available. Your app is where the risk is. If access to Threads is paid, sell it on your website,
  and in the phone app **block the pricing route itself**, not only the links to it: a tapped link to
  `app.example.com/pricing` opens the app on that page if the route exists.
- **A public support page.** The stores open your support address signed out. A page that sends them to sign in fails.
- **A review account that looks alive.** Give App Review a student sign-in and a coach sign-in, with a conversation
  already going (a question, an answer, a voice note), so no screen they open is empty. Say in the review notes that
  video and voice messages need the camera and microphone and where to find them.
- **Test on an iPad** if the build allows iPads. Review often uses one, and a failure there is a rejection.
- **Privacy answers and the privacy manifest.** People record their voice and face and write messages: declare audio,
  video (or "other user content"), messages, a user id, and the push token, from what your app really keeps. Your app
  target needs its own `PrivacyInfo.xcprivacy`; the shell's covers only the shell. Fill in the App Privacy questions
  **before** you build the archive, and write the manifest from those answers.
- **Run every changed path once, for real, before you archive.** The one rejection this recipe's source app had was a
  server error on a screen nobody had opened after the last change.
- **Raise the build number** if anything was committed since the last archive.
- **Purpose strings** (Step 2) say what the camera and microphone are for, in your product's words.
- **People can report and block.** Apps where people send each other content are asked how a person reports a message
  or a person. Have an answer (a "Report" link to your support page is the least).

---

## What Blip checked, and how

Checked without a phone, on 2026-11-12:

- **The address rule.** Sessions for `capacitor://localhost` and `https://localhost` are made only when listed, and
  refused for anything else (a wildcard, a port, a path, another scheme, a website). Unit tests and a live run.
- **Framing from `capacitor://localhost`** in the system web view on a Mac (the same engine family an iPhone's web view
  uses, on a computer): a page served on `capacitor://localhost` framed the student's screen, the screen loaded and
  its messages to the page arrived. A session made for a different address was refused by the same web view.
- **Both screens at phone sizes** (390x844 and 360x740, touch on) in headless Chrome, with a fake microphone: writing,
  a voice note recorded and sent, the video recorder opened, the inbox, a conversation opened and answered. No
  sideways scrolling, every input 16px, every button in the two screens at least 44px tall.
- **What the recorders ask the phone for** (read from the code): voice notes ask for `audio/mp4` (AAC) first, then
  `audio/webm`; video asks for `video/mp4` (H.264 and AAC) first, then `video/webm`. An iPhone's web view records MP4
  and, before iOS 18.4 by Apple's release notes, nothing else, so MP4 first is what makes it work there. Android
  records MP4 where its web view can and WebM otherwise. The camera is asked for at 1280 wide, 30 frames a second,
  front camera. A phone's web view has no screen sharing, so the recorder offers the camera only.

## What has not been tested on a real phone

None of the following has been run on an iPhone or an Android phone. Treat each as unknown until you have done it.

1. **Recording in the web view.** That the camera and microphone open inside a frame in an iPhone's and an Android
   phone's web view, that the permission question appears once and reads well, and that a recorded video and a voice
   note play back for the other person. (Recording needs iOS 14.5 or later; this recipe assumes iOS 15 or later.)
2. **The recorder's small controls.** Blip's video recorder has a few controls under 44px (close, microphone, more).
   They have not been tried with a thumb.
3. **Uploading when the app goes to the background.** A phone stops a web view soon after the app is hidden. Blip
   sends a video in pieces **while it is being recorded** and each piece can be resumed, so most of it is already sent
   when Stop is pressed; a piece that fails is tried again five times over about half a minute. But nothing uploads
   while the app is in the background: there is no background upload. If someone leaves the app during the last
   seconds of an upload, it should carry on when they come back if the page is still alive, and offer "Recover it"
   next time the recorder opens if the page was closed. Whether a phone's web view keeps the saved pieces for a
   framed page, and what it does to a recording that is still running when the app is hidden, is not known. Sizes:
   video is about 23 MB a minute at the quality Blip asks for (a phone may choose its own), so up to about 235 MB for
   the longest message, 10 minutes. A voice note is small (about 0.4 MB a minute, 5 minutes at most) and is sent in
   one go after Send: if that fails it stays on the screen to send again, and it is lost if the app is closed first.
   Until this is tested, tell people to keep the app open until a video says it is sent.
4. **The keyboard.** That the writing box stays above the keyboard inside the frame with `Keyboard.resize: "none"`
   and the Step 3 code, and that nothing zooms.
5. **Push, end to end.** Registering, the token reaching your table, APNs and FCM accepting the sends from the code in
   Step 6, the notification arriving, and a tap opening the right conversation, with the app open, in the background
   and closed. The sender code is adapted from another app's, and this version of it has never sent a push.
6. **Universal Links and App Links** opening the app on the right page.
7. **Sign-in surviving** a week of ordinary use with Preferences.
8. **App Review.** Nothing built from this guide has been submitted to either store.
