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
18 changes: 14 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion docs/guide/expo.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,26 @@ Marking the key `sensitive` hides it from EAS build logs, but `EXPO_PUBLIC_*` is

2. Tap the flame-orange bug button.
3. Fill out title + description → **Continue**.
4. Annotate the captured screenshot with the pen/arrow/rect/highlight/text tools → **Continue**.
4. Annotate the captured screenshot with the pen/arrow/rect/highlight/text tools, or pick the cursor tool to reposition something you already placed → **Continue**.
5. Review what's included → **Send report**.

The report lands in your dashboard's inbox with a Mobile / iOS / Android platform pill and a mobile-specific device card.

## Moving an annotation

The last button in the annotation toolbar is the cursor tool. With it active:

- Tap a shape to select it — a dashed outline appears around it.
- Drag the shape to move it. Text labels, arrows, rectangles, highlights and pen strokes all move.
- Tap empty canvas to deselect.

A move is a single undo step, so **Undo** puts the shape back where it was instead of deleting it.
Shapes cannot be dragged off the canvas, and the selection outline is never part of the submitted
PNG. Shapes are grabbed by their ink: an arrow or pen stroke responds near the line itself, while
rectangles, highlights and text can be grabbed anywhere inside them.

Resizing and rotating are not supported — redraw the shape instead.

## Draggable launcher

The launcher drags AssistiveTouch-style: free movement during the drag, and on release it springs to the nearest screen edge (left, right, top, or bottom) with the position along that edge preserved. It can never end up parked in the middle of the screen. The chosen edge + along-axis position persists across app restarts via AsyncStorage.
Expand Down Expand Up @@ -189,6 +204,7 @@ The queue is not encrypted — documented privacy tradeoff for v1. Don't use as
- **`Submission too fast` (400)** — the server's dwell gate (default 1000 ms) didn't pass. The SDK clamps to ≥1000 ms so this is only seen if you're running an older SDK build.
- **Wizard opens but screenshot area is blank** — `react-native-view-shot` returned without an error but produced a black frame. Usually resolved by dismissing the keyboard before opening the wizard.
- **Annotations don't appear in the submitted PNG** — older SDK bug; update to ≥0.1.0.
- **Nothing draws on the screenshot in the Annotate step (Android only)** — fixed in 0.3.5, upgrade. On Android a react-native `Modal` is a separate native window that sits outside your app's `GestureHandlerRootView`, so gesture-handler received no touches inside the wizard and the canvas ignored every stroke. The toolbar still responded, which makes this look like a broken canvas rather than a dead gesture. iOS was unaffected.
- **`xcodebuild: error: Unable to find a destination matching the provided destination specifier` / `iOS <version> is not installed`** — Xcode is installed but its iOS platform component isn't, so *no* iOS destination is eligible (not even a booted simulator). Install it via Xcode → Settings → Components, or run `xcodebuild -downloadPlatform iOS`. Confirm with `xcodebuild -workspace ios/<app>.xcworkspace -scheme <app> -showdestinations` — if every entry is listed as "Ineligible", the platform is the missing piece, not your config.
- **`run:android` fails with no emulator** — `emulator -list-avds` printing nothing means no AVD exists. Create one in Android Studio → Device Manager (this downloads a system image), or plug in a physical device with USB debugging on.
- **Report never arrives and the app logs a network error** — you're almost certainly pointing at the wrong host. Re-check [Reaching the dashboard from a device](#reaching-the-dashboard-from-a-device): an Android emulator cannot resolve `localhost` to your laptop, it needs `10.0.2.2`.
Expand Down
7 changes: 7 additions & 0 deletions packages/expo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ This SDK depends on `react-native-view-shot`, which requires a development build

Those are native builds: iOS needs Xcode **plus its iOS platform component** (Xcode → Settings → Components, or `xcodebuild -downloadPlatform iOS`) and a simulator runtime; Android needs an AVD created in Android Studio's Device Manager. Without them `run:ios` / `run:android` fails to find any destination.

## Annotating

The annotate step offers pen, arrow, rectangle, highlight and text tools, plus a cursor tool for
moving what you already placed: tap a shape to select it, drag to move it, tap empty canvas to
deselect. A move is one undo step, and the selection outline never reaches the submitted PNG.
Resizing and rotating are not supported.

## What gets captured

Every report bundles:
Expand Down
2 changes: 2 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@
"@reprojs/sdk-utils": "workspace:*",
"@reprojs/shared": "workspace:*",
"@types/react": "^18.3.3",
"@types/react-test-renderer": "18",
"bun-types": "^1.3.12",
"expo": "^52.0.0",
"react": "^18.3.1",
"react-native": "^0.76.0",
"react-test-renderer": "18.3.1",
"tsdown": "^0.21.9",
"typescript": "^5.8"
},
Expand Down
184 changes: 184 additions & 0 deletions packages/expo/src/annotation/canvas.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, expect, test } from "bun:test"
import React from "react"
import { act, create, type ReactTestInstance } from "react-test-renderer"
import {
fireGesture,
gestureOfKind,
hostType,
installNativeMocks,
} from "../test-support/native-mocks"
import type { CanvasMode } from "./mode"

installNativeMocks()

const CANVAS = { width: 300, height: 500 }

function rect(id: string, x: number, y: number) {
return { kind: "rect", id, color: "#e53935", strokeWidth: 4, x, y, w: 60, h: 40 }
}

async function mountCanvas(mode: CanvasMode, shapes: unknown[] = []) {
const { AnnotationCanvas } = await import("./canvas")
const { createAnnotationStore } = await import("./store")
const store = createAnnotationStore()
for (const shape of shapes) {
// The harness feeds in fully-formed shapes; the store only stores them.
store.addShape(shape as Parameters<typeof store.addShape>[0])
}

const selections: Array<string | null> = []
const textTaps: Array<{ x: number; y: number }> = []

// selectedId has to be real state, or the canvas would never re-render with
// a selection and the outline assertion would pass vacuously.
function Harness() {
const [selectedId, setSelectedId] = React.useState<string | null>(null)
return (
<AnnotationCanvas
width={CANVAS.width}
height={CANVAS.height}
mode={mode}
color="#e53935"
strokeWidth={4}
store={store}
selectedId={selectedId}
onSelectedIdChange={(id) => {
selections.push(id)
setSelectedId(id)
}}
onTextTap={(point) => textTaps.push(point)}
/>
)
}

let tree: ReturnType<typeof create> | null = null
act(() => {
tree = create(<Harness />)
})
if (tree === null) throw new Error("canvas did not mount")
const mounted: ReturnType<typeof create> = tree
return {
detector: mounted.root.findByType(hostType("GestureDetector")),
/** Last id handed to onSelectedIdChange, or undefined if never called. */
selection: () => selections[selections.length - 1],
textTaps,
store,
rectCount: () => mounted.root.findAllByType(hostType("Rect")).length,
}
}

function drag(
detector: ReactTestInstance,
from: { x: number; y: number },
by: { x: number; y: number },
) {
const pan = gestureOfKind(detector.props.gesture, "pan")
const at = (dx: number, dy: number) => ({
x: from.x + dx,
y: from.y + dy,
translationX: dx,
translationY: dy,
})
act(() => {
fireGesture(pan, "onStart", at(0, 0))
})
act(() => {
fireGesture(pan, "onUpdate", at(by.x, by.y))
})
act(() => {
fireGesture(pan, "onEnd", at(by.x, by.y))
})
}

function tap(detector: ReactTestInstance, at: { x: number; y: number }) {
const gesture = gestureOfKind(detector.props.gesture, "tap")
act(() => {
fireGesture(gesture, "onEnd", at)
})
}

describe("AnnotationCanvas select mode", () => {
test("tapping a shape selects it", async () => {
const { detector, selection } = await mountCanvas("select", [rect("r1", 20, 20)])
tap(detector, { x: 40, y: 30 })
expect(selection()).toBe("r1")
})

test("tapping empty canvas clears the selection", async () => {
const { detector, selection } = await mountCanvas("select", [rect("r1", 20, 20)])
tap(detector, { x: 40, y: 30 })
tap(detector, { x: 280, y: 480 })
expect(selection()).toBeNull()
})

test("dragging a shape commits the move to the store", async () => {
const { detector, store } = await mountCanvas("select", [rect("r1", 20, 20)])
drag(detector, { x: 40, y: 30 }, { x: 50, y: 60 })
expect(store.snapshot()[0]).toMatchObject({ id: "r1", x: 70, y: 80 })
})

test("a drag is one undo step, and undo restores the original position", async () => {
const { detector, store } = await mountCanvas("select", [rect("r1", 20, 20)])
drag(detector, { x: 40, y: 30 }, { x: 50, y: 60 })
act(() => {
store.undo()
})
expect(store.snapshot()[0]).toMatchObject({ id: "r1", x: 20, y: 20 })
})

test("a drag past the edge is clamped so the shape stays reachable", async () => {
const { detector, store } = await mountCanvas("select", [rect("r1", 20, 20)])
drag(detector, { x: 40, y: 30 }, { x: -999, y: -999 })
expect(store.snapshot()[0]).toMatchObject({ id: "r1", x: 0, y: 0 })
})

test("dragging from empty space moves nothing", async () => {
const { detector, store } = await mountCanvas("select", [rect("r1", 20, 20)])
drag(detector, { x: 250, y: 450 }, { x: -30, y: -30 })
expect(store.snapshot()[0]).toMatchObject({ id: "r1", x: 20, y: 20 })
// No history step was recorded, so the only undo available is still the
// one that removes the seeded shape.
act(() => {
store.undo()
})
expect(store.snapshot()).toHaveLength(0)
})

test("select mode never adds a shape", async () => {
const { detector, store } = await mountCanvas("select")
drag(detector, { x: 10, y: 10 }, { x: 80, y: 80 })
expect(store.snapshot()).toHaveLength(0)
})

test("the selected shape gains a selection outline", async () => {
// The outline is drawn by the canvas only. FlattenView renders shapes
// through renderShape, which knows nothing about selection, so a selected
// shape can never bake its outline into the submitted screenshot.
const { detector, rectCount } = await mountCanvas("select", [rect("r1", 20, 20)])
const before = rectCount()
tap(detector, { x: 40, y: 30 })
expect(rectCount()).toBe(before + 1)
})
})

describe("AnnotationCanvas drawing modes", () => {
test("pen mode still draws a stroke", async () => {
const { detector, store } = await mountCanvas("pen")
drag(detector, { x: 10, y: 10 }, { x: 40, y: 40 })
expect(store.snapshot()).toHaveLength(1)
expect(store.snapshot()[0]?.kind).toBe("pen")
})

test("rect mode still draws a rectangle from the drag extent", async () => {
const { detector, store } = await mountCanvas("rect")
drag(detector, { x: 10, y: 20 }, { x: 60, y: 40 })
expect(store.snapshot()[0]).toMatchObject({ kind: "rect", x: 10, y: 20, w: 60, h: 40 })
})

test("text mode reports the tap position instead of drawing", async () => {
const { detector, store, textTaps } = await mountCanvas("text")
tap(detector, { x: 33, y: 44 })
expect(textTaps).toEqual([{ x: 33, y: 44 }])
expect(store.snapshot()).toHaveLength(0)
})
})
Loading
Loading