diff --git a/app/access-scanner.tsx b/app/access-scanner.tsx
index c0ca9b6..1e97bd3 100644
--- a/app/access-scanner.tsx
+++ b/app/access-scanner.tsx
@@ -1,5 +1,5 @@
-import { View, Text, ActivityIndicator, AccessibilityInfo } from "react-native";
-import React, { useRef, useState } from "react";
+import { View, Text, ActivityIndicator, AccessibilityInfo, Animated, Platform } from "react-native";
+import React, { useEffect, useRef, useState } from "react";
import { useRouter } from "expo-router";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
@@ -8,7 +8,7 @@ import { AppHeader } from "../src/components/AppHeader";
import { Button } from "../src/components/Button";
import { Card } from "../src/components/Card";
import { verifyAndParseAccessQrPayload } from "../src/features/access/verifyQrPayload";
-import { describeQrSignatureError, QrSignatureError } from "../src/features/access/qrSignature";
+import { describeQrSignatureError, QrSignatureError, QR_SIGNATURE_ERROR_CODES } from "../src/features/access/qrSignature";
import {
ACCESS_QR_TYPE,
ACCESS_QR_VERSION,
@@ -49,11 +49,30 @@ const TEST_QR_PAYLOADS = {
malformedJson: "{ this is not a GuildPass QR payload",
};
+const AUTO_RESET_DELAY_MS = 3000;
+
const isUntrustedPayloadError = (error: QrPayloadError) =>
error.code === QR_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE ||
error.code === QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_VERSION ||
error.code === QR_PAYLOAD_ERROR_CODES.INVALID_KID;
+/** Determines whether a scan error should auto-reset or require manual dismissal. */
+const isRecoverableError = (error: unknown): boolean => {
+ if (error instanceof QrSignatureError) {
+ // KEY_REGISTRY_EXPIRED and PUBLIC_KEY_UNAVAILABLE are network-recoverable;
+ // all other signature errors indicate untrusted/malicious QR codes.
+ return (
+ error.code === QR_SIGNATURE_ERROR_CODES.KEY_REGISTRY_EXPIRED ||
+ error.code === QR_SIGNATURE_ERROR_CODES.PUBLIC_KEY_UNAVAILABLE
+ );
+ }
+ if (error instanceof QrPayloadError) {
+ return !isUntrustedPayloadError(error);
+ }
+ // Unexpected errors are treated as recoverable (likely transient).
+ return true;
+};
+
export default function AccessScanner() {
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
@@ -63,9 +82,53 @@ export default function AccessScanner() {
const [isProcessingScan, setIsProcessingScan] = useState(false);
const [verificationSuccess, setVerificationSuccess] = useState(false);
const scanInProgressRef = useRef(false);
+
+ // Animation values
+ const successScale = useRef(new Animated.Value(0)).current;
+ const successOpacity = useRef(new Animated.Value(0)).current;
+ const checkmarkScale = useRef(new Animated.Value(0)).current;
+ const errorSlide = useRef(new Animated.Value(20)).current;
+ const errorOpacity = useRef(new Animated.Value(0)).current;
+
const entries = useAccessHistoryStore((state) => state.entries);
const clearHistory = useAccessHistoryStore((state) => state.clearHistory);
+ // Auto-reset recoverable errors after a short delay
+ useEffect(() => {
+ if (!scanError || scanError.isUntrusted) {
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ scanInProgressRef.current = false;
+ setScanError(null);
+ setIsProcessingScan(false);
+ errorSlide.setValue(20);
+ errorOpacity.setValue(0);
+ }, AUTO_RESET_DELAY_MS);
+
+ return () => clearTimeout(timer);
+ }, [scanError]);
+
+ // Animate error card entrance
+ useEffect(() => {
+ if (scanError) {
+ Animated.parallel([
+ Animated.spring(errorSlide, {
+ toValue: 0,
+ friction: 8,
+ tension: 100,
+ useNativeDriver: true,
+ }),
+ Animated.timing(errorOpacity, {
+ toValue: 1,
+ duration: 250,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [scanError, errorSlide, errorOpacity]);
+
const handleScanData = async (data: string) => {
if (scanInProgressRef.current) {
return;
@@ -83,20 +146,51 @@ export default function AccessScanner() {
setVerificationSuccess(true);
AccessibilityInfo.announceForAccessibility("QR code accepted. Opening access check.");
- setTimeout(() => {
+ // Animate success: scale in the card, then scale the checkmark
+ successScale.setValue(0);
+ successOpacity.setValue(0);
+ checkmarkScale.setValue(0);
+
+ Animated.sequence([
+ Animated.parallel([
+ Animated.spring(successScale, {
+ toValue: 1,
+ friction: 6,
+ tension: 80,
+ useNativeDriver: true,
+ }),
+ Animated.timing(successOpacity, {
+ toValue: 1,
+ duration: 200,
+ useNativeDriver: true,
+ }),
+ ]),
+ Animated.spring(checkmarkScale, {
+ toValue: 1,
+ friction: 4,
+ tension: 120,
+ useNativeDriver: true,
+ }),
+ Animated.timing(successOpacity, {
+ toValue: 0,
+ duration: 400,
+ delay: 800,
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
setVerificationSuccess(false);
router.replace({ pathname: "/access-check", params: { qrPayload: data } });
- }, 1500);
+ });
} catch (error) {
let errorMessage = "Unable to read QR payload.";
let isUntrusted = false;
if (error instanceof QrSignatureError) {
errorMessage = describeQrSignatureError(error.code);
- isUntrusted = true;
+ isUntrusted = !isRecoverableError(error);
} else if (error instanceof QrPayloadError) {
errorMessage = describeQrPayloadError(error.code);
- isUntrusted = isUntrustedPayloadError(error);
+ isUntrusted = !isRecoverableError(error);
}
setScanError({ message: errorMessage, isUntrusted });
@@ -114,6 +208,15 @@ export default function AccessScanner() {
scanInProgressRef.current = false;
setScanError(null);
setIsProcessingScan(false);
+ errorSlide.setValue(20);
+ errorOpacity.setValue(0);
+ };
+
+ const getPermissionInstructions = (): string => {
+ if (Platform.OS === "ios") {
+ return "Open Settings > Privacy & Security > Camera, and enable camera access for GuildPass.";
+ }
+ return "Open Settings > Apps > GuildPass > Permissions, and enable camera access.";
};
if (!permission) {
@@ -136,7 +239,7 @@ export default function AccessScanner() {
const permissionMessage = permissionDenied
? permission.canAskAgain
? "Camera permission was denied. Please allow camera access to scan QR codes."
- : "Camera permission was permanently denied. Open Settings to enable camera access for GuildPass to scan QR codes."
+ : `Camera permission was permanently denied. ${getPermissionInstructions()}`
: "GuildPass needs camera permission to scan access check QR codes.";
return (
@@ -165,7 +268,6 @@ export default function AccessScanner() {
{permission.canAskAgain ? (
@@ -185,19 +287,28 @@ export default function AccessScanner() {
if (isProcessingScan) {
return (
-
-
- Processing...
-
+
+
+
+ Verifying QR code
+
+
+ Checking signature and security...
+
+
);
}
@@ -205,14 +316,26 @@ export default function AccessScanner() {
if (verificationSuccess) {
return (
-
+
- ✓
+
+ ✓
+
Signature verified
@@ -220,58 +343,81 @@ export default function AccessScanner() {
Redirecting to access check...
-
+
);
}
if (scanError) {
const isUntrusted = scanError.isUntrusted;
+ const isAutoResetting = !isUntrusted;
return (
-
-
- {isUntrusted ? "Untrusted QR code" : "QR code rejected"}
-
-
- {scanError.message}
-
-
-
+
+
+ {isUntrusted ? "⚠" : "✗"}
+
+
+ {isUntrusted ? "Untrusted QR code" : "QR code rejected"}
+
+
+
+ {scanError.message}
+
+ {isAutoResetting && (
+
+ Scanner will automatically resume...
+
+ )}
+
+
+
);
diff --git a/src/features/access/qrPayload.ts b/src/features/access/qrPayload.ts
index c067d15..6cb9c4f 100644
--- a/src/features/access/qrPayload.ts
+++ b/src/features/access/qrPayload.ts
@@ -40,20 +40,34 @@ export class QrPayloadError extends Error {
}
const QR_PAYLOAD_ERROR_MESSAGES: Record = {
- [QR_PAYLOAD_ERROR_CODES.MALFORMED_JSON]: "QR code is not a supported GuildPass access payload.",
- [QR_PAYLOAD_ERROR_CODES.MALFORMED_PAYLOAD]: "QR code payload is malformed.",
- [QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_TYPE]: "QR code payload type is not supported.",
- [QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_VERSION]: "QR code payload version is not supported. Please update your app to scan this QR code.",
- [QR_PAYLOAD_ERROR_CODES.MISSING_GUILD_ID]: "QR code is missing a valid guild ID.",
- [QR_PAYLOAD_ERROR_CODES.MISSING_RESOURCE_ID]: "QR code is missing a valid resource ID.",
- [QR_PAYLOAD_ERROR_CODES.INVALID_WALLET_ADDRESS]: "QR code contains an invalid wallet address.",
- [QR_PAYLOAD_ERROR_CODES.INVALID_WALLET_CHECKSUM]: "QR code contains a wallet address with an invalid checksum. Please rescan the code or contact the guild issuer.",
- [QR_PAYLOAD_ERROR_CODES.INVALID_EXPIRATION]: "QR code contains an invalid expiration time.",
- [QR_PAYLOAD_ERROR_CODES.EXPIRED]: "This QR code has expired.",
- [QR_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE]: "QR code contains an invalid signature.",
- [QR_PAYLOAD_ERROR_CODES.INVALID_NONCE]: "QR code contains an invalid nonce.",
- [QR_PAYLOAD_ERROR_CODES.INVALID_KID]: "QR code contains an invalid key ID.",
- [QR_PAYLOAD_ERROR_CODES.ALREADY_USED]: "This QR code has already been used.",
+ [QR_PAYLOAD_ERROR_CODES.MALFORMED_JSON]:
+ "This doesn't look like a GuildPass QR code. Make sure you're scanning a valid GuildPass access code.",
+ [QR_PAYLOAD_ERROR_CODES.MALFORMED_PAYLOAD]:
+ "The QR code couldn't be read properly. Try scanning again in better lighting, or ask the guild admin for a fresh code.",
+ [QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_TYPE]:
+ "This QR code type isn't supported. Make sure you're scanning a GuildPass access check code.",
+ [QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_VERSION]:
+ "This QR code uses a newer format. Please update GuildPass to the latest version from the app store to scan it.",
+ [QR_PAYLOAD_ERROR_CODES.MISSING_GUILD_ID]:
+ "This QR code is missing guild information. Ask the guild admin to issue a valid access code.",
+ [QR_PAYLOAD_ERROR_CODES.MISSING_RESOURCE_ID]:
+ "This QR code is missing resource information. Ask the guild admin to issue a valid access code.",
+ [QR_PAYLOAD_ERROR_CODES.INVALID_WALLET_ADDRESS]:
+ "The wallet address in this QR code is invalid. Ask the guild admin to issue a corrected code.",
+ [QR_PAYLOAD_ERROR_CODES.INVALID_WALLET_CHECKSUM]:
+ "The wallet address in this QR code has a checksum error. Try rescanning the code in better lighting, or ask the guild admin to issue a new one.",
+ [QR_PAYLOAD_ERROR_CODES.INVALID_EXPIRATION]:
+ "This QR code has an invalid expiration. Ask the guild admin to issue a new access code.",
+ [QR_PAYLOAD_ERROR_CODES.EXPIRED]:
+ "This QR code has expired. Ask the guild admin to issue a new access code for entry.",
+ [QR_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE]:
+ "The QR code signature is invalid. Do not use this code; ask the guild admin for a fresh one.",
+ [QR_PAYLOAD_ERROR_CODES.INVALID_NONCE]:
+ "This QR code has an invalid security token. Ask the guild admin to issue a new code.",
+ [QR_PAYLOAD_ERROR_CODES.INVALID_KID]:
+ "This QR code uses an unrecognized key. Ask the guild admin to issue a new access code.",
+ [QR_PAYLOAD_ERROR_CODES.ALREADY_USED]:
+ "This QR code has already been used. Each access code can only be used once. Ask the guild admin for a new code.",
};
export const describeQrPayloadError = (code: QrPayloadErrorCode): string =>
diff --git a/src/features/access/qrSignature.ts b/src/features/access/qrSignature.ts
index b577008..67c435d 100644
--- a/src/features/access/qrSignature.ts
+++ b/src/features/access/qrSignature.ts
@@ -57,21 +57,21 @@ export class QrSignatureError extends Error {
const QR_SIGNATURE_ERROR_MESSAGES: Record = {
[QR_SIGNATURE_ERROR_CODES.MISSING_SIGNATURE]:
- "This QR code is missing its security signature. Ask the guild admin to issue a new code.",
+ "This QR code is missing its security signature. Ask the guild admin to issue a properly signed code.",
[QR_SIGNATURE_ERROR_CODES.INVALID_SIGNATURE_FORMAT]:
- "The QR code signature is malformed. Re-scan the code or ask the guild admin for a fresh one.",
+ "The QR code signature is malformed. Try scanning again in better lighting, or ask the guild admin for a fresh code.",
[QR_SIGNATURE_ERROR_CODES.VERIFICATION_FAILED]:
- "The QR code signature could not be verified. Do not use this code; ask the guild admin for a fresh one.",
+ "The QR code signature could not be verified. This may indicate a tampered or forged code — do not use it. Ask the guild admin for a fresh one.",
[QR_SIGNATURE_ERROR_CODES.PUBLIC_KEY_UNAVAILABLE]:
- "The guild issuer key is unavailable. Try again later or contact the guild admin.",
+ "Unable to verify this guild's identity. Check your internet connection and try again, or contact the guild admin.",
[QR_SIGNATURE_ERROR_CODES.REVOKED_KEY]:
- "This QR code was signed with a revoked guild key. Contact the guild admin for a new code.",
+ "This QR code was signed with a revoked guild key. The guild has rotated their security keys. Ask the guild admin for a newly issued code.",
[QR_SIGNATURE_ERROR_CODES.UNKNOWN_KEY]:
- "This QR code was signed by an unknown guild key. Contact the guild admin before using it.",
+ "This QR code was signed by an unrecognized guild key. Do not use this code — contact the guild admin to verify it's genuine before proceeding.",
[QR_SIGNATURE_ERROR_CODES.MISSING_KID]:
- "This QR code is missing its key identifier. Ask the guild admin to reissue it.",
+ "This QR code is missing its key identifier. Ask the guild admin to reissue a properly formatted code.",
[QR_SIGNATURE_ERROR_CODES.KEY_REGISTRY_EXPIRED]:
- "The guild key registry is stale. Reconnect to the internet and scan again.",
+ "The guild key registry is out of date. Make sure you're connected to the internet and try scanning again.",
};
export const describeQrSignatureError = (code: QrSignatureErrorCode): string =>
diff --git a/tests/access-scanner.test.tsx b/tests/access-scanner.test.tsx
index 15317b9..eb83950 100644
--- a/tests/access-scanner.test.tsx
+++ b/tests/access-scanner.test.tsx
@@ -4,32 +4,59 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import AccessScanner from "../app/access-scanner";
// Mock react-native
-vi.mock("react-native", () => ({
- View: "View",
- Text: "Text",
- ScrollView: "ScrollView",
- TextInput: "TextInput",
- TouchableOpacity: "TouchableOpacity",
- ActivityIndicator: "ActivityIndicator",
- SafeAreaView: "SafeAreaView",
- StyleSheet: { create: (styles: Record) => styles },
- Platform: { OS: "ios", select: (objs: Record) => objs.ios ?? objs.default },
- DeviceEventEmitter: {
- addListener: vi.fn(() => ({ remove: vi.fn() })),
- removeListener: vi.fn(),
- emit: vi.fn(),
- },
- NativeModules: {},
- NativeEventEmitter: vi.fn(() => ({
- addListener: vi.fn(() => ({ remove: vi.fn() })),
- removeListener: vi.fn(),
- })),
- Linking: {
- openURL: vi.fn(),
- canOpenURL: vi.fn(),
- addEventListener: vi.fn(() => ({ remove: vi.fn() })),
- },
-}));
+vi.mock("react-native", () => {
+ // --------------- shared animation helpers ---------------
+ const createAnimatable = () => ({
+ start: (callback?: () => void) => {
+ queueMicrotask(() => callback?.());
+ },
+ });
+
+ class AnimatedValue {
+ _value: number;
+ constructor(value: number) {
+ this._value = value;
+ }
+ setValue(_value: number) {}
+ }
+
+ return {
+ View: "View",
+ Text: "Text",
+ ScrollView: "ScrollView",
+ TextInput: "TextInput",
+ TouchableOpacity: "TouchableOpacity",
+ ActivityIndicator: "ActivityIndicator",
+ SafeAreaView: "SafeAreaView",
+ StyleSheet: { create: (styles: Record) => styles },
+ Platform: { OS: "ios", select: (objs: Record) => objs.ios ?? objs.default },
+ DeviceEventEmitter: {
+ addListener: vi.fn(() => ({ remove: vi.fn() })),
+ removeListener: vi.fn(),
+ emit: vi.fn(),
+ },
+ NativeModules: {},
+ NativeEventEmitter: vi.fn(() => ({
+ addListener: vi.fn(() => ({ remove: vi.fn() })),
+ removeListener: vi.fn(),
+ })),
+ Linking: {
+ openURL: vi.fn(),
+ canOpenURL: vi.fn(),
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
+ },
+ Animated: {
+ parallel: createAnimatable,
+ sequence: createAnimatable,
+ spring: createAnimatable,
+ timing: createAnimatable,
+ loop: createAnimatable,
+ View: "Animated.View",
+ Text: "Animated.Text",
+ Value: AnimatedValue,
+ },
+ };
+});
// Mock expo-camera
vi.mock("expo-camera", () => ({
diff --git a/tests/accessScanner.test.tsx b/tests/accessScanner.test.tsx
index 0ff62c3..1becf8c 100644
--- a/tests/accessScanner.test.tsx
+++ b/tests/accessScanner.test.tsx
@@ -17,33 +17,73 @@ const accessibilityInfoMock = vi.hoisted(() => ({
announceForAccessibility: vi.fn(),
}));
-vi.mock("react-native", () => ({
- View: "View",
- Text: "Text",
- ScrollView: "ScrollView",
- TextInput: "TextInput",
- TouchableOpacity: "TouchableOpacity",
- ActivityIndicator: "ActivityIndicator",
- SafeAreaView: "SafeAreaView",
- StyleSheet: { create: (styles: Record) => styles },
- Platform: { OS: "ios", select: (objs: Record) => objs.ios ?? objs.default },
- DeviceEventEmitter: {
- addListener: vi.fn(() => ({ remove: vi.fn() })),
- removeListener: vi.fn(),
- emit: vi.fn(),
- },
- NativeModules: {},
- NativeEventEmitter: vi.fn(() => ({
- addListener: vi.fn(() => ({ remove: vi.fn() })),
- removeListener: vi.fn(),
- })),
- Linking: {
- openURL: vi.fn(),
- canOpenURL: vi.fn(),
- addEventListener: vi.fn(() => ({ remove: vi.fn() })),
- },
- AccessibilityInfo: accessibilityInfoMock,
-}));
+vi.mock("react-native", () => {
+ // Animation helpers: store the completion callback so it can be
+ // invoked synchronously in tests (matching the animation-driven
+ // navigation flow).
+ const createAnimatable = () => {
+ let completion: (() => void) | undefined;
+ const start = (callback?: () => void) => {
+ completion = callback;
+ // Call synchronously so animation-driven flows (e.g. navigation
+ // after success animation) don't need real timers.
+ queueMicrotask(() => completion?.());
+ };
+ return { start, _completion: () => completion };
+ };
+
+ const animationMethods = {
+ parallel: () => createAnimatable(),
+ sequence: () => createAnimatable(),
+ spring: () => createAnimatable(),
+ timing: () => createAnimatable(),
+ loop: () => createAnimatable(),
+ };
+
+ class AnimatedValue {
+ _value: number;
+ constructor(value: number) {
+ this._value = value;
+ }
+ setValue(value: number) {
+ this._value = value;
+ }
+ }
+
+ return {
+ View: "View",
+ Text: "Text",
+ ScrollView: "ScrollView",
+ TextInput: "TextInput",
+ TouchableOpacity: "TouchableOpacity",
+ ActivityIndicator: "ActivityIndicator",
+ SafeAreaView: "SafeAreaView",
+ StyleSheet: { create: (styles: Record) => styles },
+ Platform: { OS: "ios", select: (objs: Record) => objs.ios ?? objs.default },
+ DeviceEventEmitter: {
+ addListener: vi.fn(() => ({ remove: vi.fn() })),
+ removeListener: vi.fn(),
+ emit: vi.fn(),
+ },
+ NativeModules: {},
+ NativeEventEmitter: vi.fn(() => ({
+ addListener: vi.fn(() => ({ remove: vi.fn() })),
+ removeListener: vi.fn(),
+ })),
+ Linking: {
+ openURL: vi.fn(),
+ canOpenURL: vi.fn(),
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
+ },
+ AccessibilityInfo: accessibilityInfoMock,
+ Animated: {
+ ...animationMethods,
+ View: "Animated.View",
+ Text: "Animated.Text",
+ Value: AnimatedValue,
+ },
+ };
+});
type MockCameraViewProps = {
onBarcodeScanned?: (result: { data: string }) => Promise;
@@ -138,6 +178,10 @@ vi.mock("../src/features/access/qrSignature", () => ({
describeQrSignatureError: describeQrSignatureErrorMock,
}));
+vi.mock("../src/features/offline/mutationQueue", () => ({
+ useMutationQueue: () => [],
+}));
+
const screenText = (renderer: ReactTestRenderer) => JSON.stringify(renderer.toJSON());
describe("AccessScanner", () => {
@@ -173,14 +217,15 @@ describe("AccessScanner", () => {
expect(output).toContain("accessibilityRole");
});
- it("shows permanent denial message when camera denied and cannot ask again", () => {
+ it("shows permanent denial message with platform-specific instructions when camera denied and cannot ask again", () => {
mockCameraPermission(createPermissionResponse(false, false));
const renderer = TestRenderer.create();
- expect(screenText(renderer)).toContain(
- "Camera permission was permanently denied. Open Settings to enable camera access for GuildPass to scan QR codes.",
- );
+ const output = screenText(renderer);
+ expect(output).toContain("Camera permission was permanently denied.");
+ // Platform is mocked as iOS, so iOS-specific settings path is shown
+ expect(output).toContain("Privacy & Security");
});
it("shows scanner view when permission is granted", () => {
@@ -284,8 +329,9 @@ describe("AccessScanner", () => {
await cameraProps.onBarcodeScanned?.({ data: "payload" });
});
- act(() => {
- vi.runAllTimers();
+ // Wait for the animation microtask to fire the navigation callback
+ await act(async () => {
+ await new Promise((resolve) => queueMicrotask(resolve));
});
expect(verifyAndParseAccessQrPayloadMock).toHaveBeenCalledWith("payload");
@@ -334,6 +380,11 @@ describe("AccessScanner", () => {
vi.runAllTimers();
});
+ // Wait for the animation microtask to fire the navigation callback
+ await act(async () => {
+ await new Promise((resolve) => queueMicrotask(resolve));
+ });
+
expect(routerMocks.replace).toHaveBeenCalledTimes(1);
expect(routerMocks.replace).toHaveBeenCalledWith({
pathname: "/access-check",
@@ -429,7 +480,8 @@ describe("AccessScanner", () => {
});
act(() => {
- renderer.root.findByProps({ accessibilityLabel: "Scan Again" }).props.onPress();
+ // Recoverable errors use "Scan Again Now" label with auto-reset indicator
+ renderer.root.findByProps({ accessibilityLabel: "Scan Again Now" }).props.onPress();
});
await act(async () => {
@@ -442,8 +494,9 @@ describe("AccessScanner", () => {
await cameraProps.onBarcodeScanned?.({ data: "good" });
});
- act(() => {
- vi.runAllTimers();
+ // Wait for the animation microtask to fire the navigation callback
+ await act(async () => {
+ await new Promise((resolve) => queueMicrotask(resolve));
});
expect(routerMocks.replace).toHaveBeenCalledWith({
@@ -479,13 +532,146 @@ describe("AccessScanner", () => {
await getCameraProps().onBarcodeScanned?.({ data: "bad" });
});
- // Guard should be reset after error — invoke handler directly on same ref
+ // Guard should be reset after error — invoke handler directly on same ref.
+ // First advance past the auto-reset timer to clear the error state,
+ // then scan the good data.
+ act(() => {
+ vi.advanceTimersByTime(4000);
+ });
+
await act(async () => {
await getCameraProps().onBarcodeScanned?.({ data: "good" });
});
+ // Wait for the animation microtask to fire the navigation callback
+ await act(async () => {
+ await new Promise((resolve) => queueMicrotask(resolve));
+ });
+
+ expect(routerMocks.replace).toHaveBeenCalledWith({
+ pathname: "/access-check",
+ params: { qrPayload: "good" },
+ });
+ });
+
+ it("auto-resets the scanner UI after a recoverable error", async () => {
+ mockCameraPermission(createPermissionResponse(true, true));
+ verifyAndParseAccessQrPayloadMock.mockRejectedValue(new Error("forged"));
+
+ TestRenderer.create();
+
+ const getCameraProps = () => {
+ const props = cameraViewMock.mock.calls.at(-1)?.[0];
+ if (!props) throw new Error("CameraView did not render");
+ return props;
+ };
+
+ // First scan → recoverable error
+ await act(async () => {
+ await getCameraProps().onBarcodeScanned?.({ data: "bad" });
+ });
+
+ // Auto-reset should clear error after delay and return to camera view
act(() => {
- vi.runAllTimers();
+ vi.advanceTimersByTime(4000);
+ });
+
+ // Now a subsequent scan should be accepted (proving auto-reset worked)
+ verifyAndParseAccessQrPayloadMock.mockResolvedValueOnce({
+ payload: {
+ guildId: "guild-alpha",
+ resourceId: "vip-door",
+ walletAddress: "0xabc",
+ expiresAt: "2099-01-01T00:00:00.000Z",
+ },
+ isVerified: true,
+ });
+
+ await act(async () => {
+ await getCameraProps().onBarcodeScanned?.({ data: "good" });
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => queueMicrotask(resolve));
+ });
+
+ expect(routerMocks.replace).toHaveBeenCalledWith({
+ pathname: "/access-check",
+ params: { qrPayload: "good" },
+ });
+ });
+
+ it("does not auto-reset the scanner UI for untrusted (signature) errors", async () => {
+ mockCameraPermission(createPermissionResponse(true, true));
+ verifyAndParseAccessQrPayloadMock.mockRejectedValue(
+ new QrSignatureErrorMock(QR_SIGNATURE_ERROR_CODES_MOCK.VERIFICATION_FAILED),
+ );
+
+ TestRenderer.create();
+
+ const getCameraProps = () => {
+ const props = cameraViewMock.mock.calls.at(-1)?.[0];
+ if (!props) throw new Error("CameraView did not render");
+ return props;
+ };
+
+ await act(async () => {
+ await getCameraProps().onBarcodeScanned?.({ data: "bad" });
+ });
+
+ // Advance past auto-reset time — error should persist because it's untrusted
+ act(() => {
+ vi.advanceTimersByTime(4000);
+ });
+
+ // Error state should still be showing (no "Point your camera" text)
+ // A subsequent scan should NOT be accepted because the guard re-arms
+ // but the error UI persists for untrusted codes, blocking the camera.
+ // The scan ref guard is reset, but the user must manually dismiss.
+ expect(routerMocks.replace).not.toHaveBeenCalled();
+ });
+
+ it("auto-resets for network-recoverable signature errors (KEY_REGISTRY_EXPIRED)", async () => {
+ mockCameraPermission(createPermissionResponse(true, true));
+ verifyAndParseAccessQrPayloadMock.mockRejectedValue(
+ new QrSignatureErrorMock(QR_SIGNATURE_ERROR_CODES_MOCK.KEY_REGISTRY_EXPIRED),
+ );
+
+ TestRenderer.create();
+
+ const getCameraProps = () => {
+ const props = cameraViewMock.mock.calls.at(-1)?.[0];
+ if (!props) throw new Error("CameraView did not render");
+ return props;
+ };
+
+ // First scan -> KEY_REGISTRY_EXPIRED (network-recoverable, should auto-reset)
+ await act(async () => {
+ await getCameraProps().onBarcodeScanned?.({ data: "bad" });
+ });
+
+ // Auto-reset should clear error after delay
+ act(() => {
+ vi.advanceTimersByTime(4000);
+ });
+
+ // Now a subsequent scan should be accepted (proving auto-reset worked)
+ verifyAndParseAccessQrPayloadMock.mockResolvedValueOnce({
+ payload: {
+ guildId: "guild-alpha",
+ resourceId: "vip-door",
+ walletAddress: "0xabc",
+ expiresAt: "2099-01-01T00:00:00.000Z",
+ },
+ isVerified: true,
+ });
+
+ await act(async () => {
+ await getCameraProps().onBarcodeScanned?.({ data: "good" });
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => queueMicrotask(resolve));
});
expect(routerMocks.replace).toHaveBeenCalledWith({