Skip to content
Open
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
9 changes: 7 additions & 2 deletions apps/companion/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"associatedDomains": ["applinks:app.shelf.nu"],
"infoPlist": {
"NSCameraUsageDescription": "Shelf needs camera access to scan QR codes and barcodes on your assets.",
"NSPhotoLibraryUsageDescription": "Shelf needs photo library access so you can attach photos to assets and audits."
"NSPhotoLibraryUsageDescription": "Shelf needs photo library access so you can attach photos to assets and audits.",
"NSLocationWhenInUseUsageDescription": "Shelf records where assets were scanned so your team can see each item's last known location."
}
},
"android": {
Expand All @@ -23,7 +24,11 @@
"backgroundColor": "#FF7809"
},
"package": "com.shelf.companion",
"permissions": ["android.permission.CAMERA"],
"permissions": [
"android.permission.CAMERA",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION"
],
"intentFilters": [
{
"action": "VIEW",
Expand Down
23 changes: 22 additions & 1 deletion apps/companion/app/(tabs)/scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { fontSize, spacing, borderRadius } from "@/lib/constants";
import { useTheme } from "@/lib/theme-context";
import { createStyles } from "@/lib/create-styles";
import { extractQrId } from "@/lib/qr-utils";
import { primeScanLocation, getScanCoordinates } from "@/lib/scan-location";
import { parseSequentialId } from "@/lib/sequential-id";
import { announce } from "@/lib/a11y";
import { playScanSound } from "@/lib/scan-sound";
Expand Down Expand Up @@ -190,6 +191,19 @@ function ScannerContent() {
const styles = useStyles();
const [permission, requestPermission] = useCameraPermissions();

/**
* Prime scan geolocation on scanner use: lazily requests location
* permission (once per session — see scan-location.ts) and warms a
* background position fix so scans can attach coordinates without waiting
* on GPS. Gated on camera permission so the location prompt never stacks
* on top of the camera prompt during first run; granting camera re-runs
* this effect. Never gates scanning — denied simply means scans are
* recorded without coordinates.
*/
useEffect(() => {
if (isFocused && permission?.granted) primeScanLocation();
}, [isFocused, permission?.granted]);

// Booking check-in mode
const isBookingMode = !!bookingId;
// Fulfil-and-check-out flow (book-by-model): the booking reserved N units of
Expand Down Expand Up @@ -695,13 +709,20 @@ function ScannerContent() {
return;
}

// Best-effort scan geolocation (web parity). Cached / last-known
// position only, bounded to ~1.5s worst case and usually instant —
// never a fresh GPS fix in the scan hot path (see scan-location.ts).
// Null (no permission / no recent fix / timeout) simply means the
// scan is recorded without coordinates.
const coordinates = await getScanCoordinates();

// orgId is only consumed by the server's SAM branch; on the QR path
// the org is derived from the QR record and this is ignored.
const {
data: qrData,
error,
errorDetails,
} = await api.qr(qrLookupId, currentOrg?.id);
} = await api.qr(qrLookupId, currentOrg?.id, coordinates);

if (error || !qrData) {
flashFrame("error");
Expand Down
2 changes: 2 additions & 0 deletions apps/companion/ios/Shelf/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>Shelf needs camera access to scan QR codes and barcodes on your assets.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Shelf records where assets were scanned so your team can see each item's last known location.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Shelf needs photo access to attach images to your assets.</string>
<key>NSUserActivityTypes</key>
Expand Down
28 changes: 20 additions & 8 deletions apps/companion/ios/Shelf/PrivacyInfo.xcprivacy
Original file line number Diff line number Diff line change
Expand Up @@ -47,60 +47,72 @@
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeName</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeUserID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePreciseLocation</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
</array>
<key>NSPrivacyTracking</key>
Expand Down
40 changes: 34 additions & 6 deletions apps/companion/lib/api/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,43 @@ export const assetsApi = {
* field-scan contexts (scanner tab, deep links). To resolve a code WITHOUT
* recording (e.g. the audit scanner), use {@link getScannedItem} instead.
*
* Wire contract for coordinates (matches the route's Zod schema): optional
* `x-shelf-scan-latitude` / `x-shelf-scan-longitude` HEADERS, decimal
* degrees, both or neither (lat −90..90, lng −180..180). They are
* best-effort provenance — the server silently ignores invalid values and a
* resolve NEVER fails because of them, mirroring the web flow's non-fatal
* geolocation post.
*
* why headers, not query params: a URL is logged everywhere (proxy access
* logs, APM traces, Sentry breadcrumbs). Precise GPS in the query string
* would leak the user's position into all of them. The web sibling already
* keeps coordinates out of the URL by posting them in a request body.
*
* @param codeId - The scanned QR id or normalized SAM id.
* @param orgId - Caller's current workspace id; required for SAM lookups.
* @param coordinates - Optional GPS position captured at scan time (see
* `lib/scan-location.ts`); attached to the recorded scan server-side.
*/
qr: (codeId: string, orgId?: string) =>
apiFetch<QrResponse>(
`/api/mobile/qr/${encodeURIComponent(codeId)}${
orgId ? `?orgId=${orgId}` : ""
}`
),
qr: (
codeId: string,
orgId?: string,
coordinates?: { latitude: number; longitude: number } | null
) => {
const searchParams = new URLSearchParams();
if (orgId) searchParams.set("orgId", orgId);
const query = searchParams.toString();
return apiFetch<QrResponse>(
`/api/mobile/qr/${encodeURIComponent(codeId)}${query ? `?${query}` : ""}`,
coordinates
? {
headers: {
"x-shelf-scan-latitude": String(coordinates.latitude),
"x-shelf-scan-longitude": String(coordinates.longitude),
},
}
: undefined
);
},

/**
* Resolve a scanned code to an asset or kit **without recording** a scan,
Expand Down
10 changes: 9 additions & 1 deletion apps/companion/lib/deep-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect } from "react";
import * as Linking from "expo-linking";
import { api } from "./api";
import { openShelfWebUrl, pushIntoTab } from "./navigation";
import { getScanCoordinates } from "./scan-location";

/**
* Supported deep link patterns:
Expand Down Expand Up @@ -104,7 +105,14 @@ function parseDeepLink(url: string): ParsedLink {
*/
async function resolveQrAndNavigate(qrId: string) {
try {
const { data, error } = await api.qr(qrId);
// Best-effort scan geolocation: a /qr deep link usually means the user
// physically scanned the label with the OS camera, so the recorded scan
// gets the same "where" treatment as the in-app scanner. Read-only:
// getScanCoordinates never shows a permission prompt (only the scanner
// primes permission), returns within ~1.5s, and null simply means the
// scan is recorded without coordinates.
const coordinates = await getScanCoordinates();
const { data, error } = await api.qr(qrId, undefined, coordinates);
if (!error && data?.qr?.asset?.id) {
pushIntoTab("/(tabs)/assets", `/(tabs)/assets/${data.qr.asset.id}`);
return;
Expand Down
Loading
Loading