diff --git a/apps/expo-glyphnet/bun.lock b/apps/expo-glyphnet/bun.lock index 8e224f1..3a8e458 100644 --- a/apps/expo-glyphnet/bun.lock +++ b/apps/expo-glyphnet/bun.lock @@ -16,7 +16,9 @@ "expo-font": "~56.0.5", "expo-glass-effect": "~56.0.4", "expo-image": "~56.0.9", + "expo-image-manipulator": "~56.0.15", "expo-linking": "~56.0.12", + "expo-media-library": "~56.0.6", "expo-navigation-bar": "~56.0.3", "expo-print": "~56.0.3", "expo-router": "~56.2.7", @@ -793,10 +795,16 @@ "expo-image": ["expo-image@56.0.9", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-FifiRehXnMul5XeUVHWv+COHFUeCAdsYf5MiCPUBlhr4pRb0sxjA4/floi/TEDpATOIw6GqxbrC4FdZBoyrJmw=="], + "expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="], + + "expo-image-manipulator": ["expo-image-manipulator@56.0.15", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-nM9tgsKwLv3d/aLCaPqO5RePH1VmBWVTkRK2NP2sG6LEJgNw5RkGCaXoux+UNS3kPzzDTUTENRrx7eKqAKT/kQ=="], + "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="], "expo-linking": ["expo-linking@56.0.12", "", { "dependencies": { "expo-constants": "~56.0.16", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ=="], + "expo-media-library": ["expo-media-library@56.0.6", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-UsyVcxP7Op9ErFFLW1xImjoKFgKi7XSw8hrCfzf2yIG+OgVb9dsQth0mVRPgfRxdELagsUslXc1QXTiW8dpbaQ=="], + "expo-modules-autolinking": ["expo-modules-autolinking@56.0.14", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg=="], "expo-modules-core": ["expo-modules-core@56.0.13", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", "expo-modules-jsi": "~56.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ=="], diff --git a/apps/expo-glyphnet/package.json b/apps/expo-glyphnet/package.json index 811a1e4..c7436a0 100644 --- a/apps/expo-glyphnet/package.json +++ b/apps/expo-glyphnet/package.json @@ -14,7 +14,9 @@ "expo-font": "~56.0.5", "expo-glass-effect": "~56.0.4", "expo-image": "~56.0.9", + "expo-image-manipulator": "~56.0.15", "expo-linking": "~56.0.12", + "expo-media-library": "~56.0.6", "expo-navigation-bar": "~56.0.3", "expo-print": "~56.0.3", "expo-router": "~56.2.7", diff --git a/apps/expo-glyphnet/src/features/encode/EncodePanel.tsx b/apps/expo-glyphnet/src/features/encode/EncodePanel.tsx index d276d7b..52f6936 100644 --- a/apps/expo-glyphnet/src/features/encode/EncodePanel.tsx +++ b/apps/expo-glyphnet/src/features/encode/EncodePanel.tsx @@ -1,4 +1,5 @@ import * as FileSystem from "expo-file-system"; +import * as MediaLibrary from "expo-media-library"; import * as Print from "expo-print"; import { useMemo, useState } from "react"; import { SvgXml } from "react-native-svg"; @@ -6,6 +7,24 @@ import { SvgXml } from "react-native-svg"; import { scannerAdapter } from "@/adapters/scanner"; import { Pressable, Text, TextInput, View } from "@/tw"; +function getWritableBaseDir(): string | null { + const fsAny = FileSystem as unknown as { + documentDirectory?: string | null; + cacheDirectory?: string | null; + Paths?: { + document?: { uri?: string }; + cache?: { uri?: string }; + }; + }; + return ( + fsAny.documentDirectory ?? + fsAny.cacheDirectory ?? + fsAny.Paths?.document?.uri ?? + fsAny.Paths?.cache?.uri ?? + null + ); +} + export function EncodePanel() { const [payload, setPayload] = useState("hello glyphnet"); const [svgPreview, setSvgPreview] = useState(""); @@ -44,7 +63,7 @@ export function EncodePanel() { return; } try { - const dir = FileSystem.documentDirectory ?? FileSystem.cacheDirectory; + const dir = getWritableBaseDir(); if (!dir) { setActionMessage("Unable to access local storage."); return; @@ -53,6 +72,17 @@ export function EncodePanel() { await FileSystem.writeAsStringAsync(uri, svgPreview, { encoding: FileSystem.EncodingType.UTF8, }); + let savedToLibrary = false; + try { + const perm = await MediaLibrary.requestPermissionsAsync(); + if (perm.granted) { + await MediaLibrary.saveToLibraryAsync(uri); + savedToLibrary = true; + } + } catch { + // Ignore and fall back to share/export. + } + // Load sharing lazily so Expo Go / unsupported runtimes never fail at import time. try { const Sharing = await import("expo-sharing"); @@ -61,12 +91,20 @@ export function EncodePanel() { mimeType: "image/svg+xml", dialogTitle: "Share GlyphNet SVG", }); - setActionMessage("SVG saved and share sheet opened."); + setActionMessage( + savedToLibrary + ? "SVG saved to library and share sheet opened." + : "SVG exported and share sheet opened.", + ); } else { - setActionMessage(`SVG saved: ${uri}`); + setActionMessage( + savedToLibrary ? "SVG saved to library." : `SVG saved in app storage: ${uri}`, + ); } } catch { - setActionMessage(`SVG saved: ${uri}`); + setActionMessage( + savedToLibrary ? "SVG saved to library." : `SVG saved in app storage: ${uri}`, + ); } } catch (error) { setActionMessage( diff --git a/apps/expo-glyphnet/src/features/scan/ScanPanel.tsx b/apps/expo-glyphnet/src/features/scan/ScanPanel.tsx index 46f9274..2d18587 100644 --- a/apps/expo-glyphnet/src/features/scan/ScanPanel.tsx +++ b/apps/expo-glyphnet/src/features/scan/ScanPanel.tsx @@ -1,5 +1,7 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import * as NavigationBar from "expo-navigation-bar"; +import * as FileSystem from "expo-file-system"; +import * as ImageManipulator from "expo-image-manipulator"; import { useMemo, useRef, useState } from "react"; import { Modal, Vibration } from "react-native"; import { Platform, useColorScheme } from "react-native"; @@ -14,340 +16,523 @@ const MODES = ["print", "screen", "burst"] as const; // Wide ribbon-like scan guide (GlyphNet print profile), centered in portrait view. const GUIDE_ROI = { x: 0.06, y: 0.34, w: 0.88, h: 0.26 } as const; +function getWritableBaseDir(): string | null { + const fsAny = FileSystem as unknown as { + documentDirectory?: string | null; + cacheDirectory?: string | null; + Paths?: { + document?: { uri?: string }; + cache?: { uri?: string }; + }; + }; + return ( + fsAny.documentDirectory ?? + fsAny.cacheDirectory ?? + fsAny.Paths?.document?.uri ?? + fsAny.Paths?.cache?.uri ?? + null + ); +} + export function ScanPanel() { - const cameraRef = useRef(null); - const insets = useSafeAreaInsets(); - const colorScheme = useColorScheme(); - const [permission, requestPermission] = useCameraPermissions(); - const [mode, setMode] = useState<(typeof MODES)[number]>("print"); - const [verifyKeyHex, setVerifyKeyHex] = useState(""); - const [result, setResult] = useState(""); - const [resultOpen, setResultOpen] = useState(false); - const [debugEnabled, setDebugEnabled] = useState(false); - const [lastCaptureDataUri, setLastCaptureDataUri] = useState(null); - const [captureMs, setCaptureMs] = useState(null); - const [scanMs, setScanMs] = useState(null); - const [torchEnabled, setTorchEnabled] = useState(false); - const [loading, setLoading] = useState(false); + const cameraRef = useRef(null); + const insets = useSafeAreaInsets(); + const colorScheme = useColorScheme(); + const [permission, requestPermission] = useCameraPermissions(); + const [mode, setMode] = useState<(typeof MODES)[number]>("print"); + const [verifyKeyHex, setVerifyKeyHex] = useState(""); + const [result, setResult] = useState(""); + const [resultOpen, setResultOpen] = useState(false); + const [debugEnabled, setDebugEnabled] = useState(false); + const [lastCaptureDataUri, setLastCaptureDataUri] = useState( + null, + ); + const [lastCaptureUri, setLastCaptureUri] = useState(null); + const [lastCaptureWidth, setLastCaptureWidth] = useState( + null, + ); + const [lastCaptureHeight, setLastCaptureHeight] = useState( + null, + ); + const [captureMs, setCaptureMs] = useState(null); + const [scanMs, setScanMs] = useState(null); + const [torchEnabled, setTorchEnabled] = useState(false); + const [loading, setLoading] = useState(false); + const [debugSaveMessage, setDebugSaveMessage] = useState( + null, + ); + + const statusLabel = useMemo(() => { + if (!permission) { + return "Checking camera permission..."; + } + return permission.granted + ? "Camera ready" + : "Camera access required for live scanning"; + }, [permission]); - const statusLabel = useMemo(() => { - if (!permission) { - return "Checking camera permission..."; - } - return permission.granted - ? "Camera ready" - : "Camera access required for live scanning"; - }, [permission]); + useEffect(() => { + if (Platform.OS !== "android" || colorScheme !== "dark") { + return; + } + const applyNavStyle = async () => { + try { + await NavigationBar.setBackgroundColorAsync("#020617"); + await NavigationBar.setButtonStyleAsync("light"); + } catch { + // Ignore runtime/platform cases where nav bar styling is unavailable. + } + }; + void applyNavStyle(); + }, [colorScheme]); - useEffect(() => { - if (Platform.OS !== "android" || colorScheme !== "dark") { - return; - } - const applyNavStyle = async () => { - try { - await NavigationBar.setBackgroundColorAsync("#020617"); - await NavigationBar.setButtonStyleAsync("light"); - } catch { - // Ignore runtime/platform cases where nav bar styling is unavailable. - } + const runScan = async () => { + setLoading(true); + try { + if (!cameraRef.current?.takePictureAsync) { + setResult("Camera capture is not ready yet."); + return; + } + const captureStart = Date.now(); + const shot = await cameraRef.current.takePictureAsync({ + base64: true, + quality: 0.9, + shutterSound: false, + }); + const captureElapsed = Date.now() - captureStart; + setCaptureMs(captureElapsed); + if (!shot?.base64) { + setResult("Failed to capture image from camera."); + return; + } + setLastCaptureUri(shot.uri ?? null); + setLastCaptureWidth( + typeof shot.width === "number" ? shot.width : null, + ); + setLastCaptureHeight( + typeof shot.height === "number" ? shot.height : null, + ); + if (debugEnabled) { + setLastCaptureDataUri(`data:image/jpeg;base64,${shot.base64}`); + } + const scanStart = Date.now(); + const json = await scannerAdapter.scanStill({ + mode, + verifyKeyHex: verifyKeyHex || undefined, + verifyKeyId: 1, + imageBase64: shot.base64, + roiX: GUIDE_ROI.x, + roiY: GUIDE_ROI.y, + roiW: GUIDE_ROI.w, + roiH: GUIDE_ROI.h, + }); + const scanElapsed = Date.now() - scanStart; + setScanMs(scanElapsed); + setResult(JSON.stringify(json, null, 2)); + if (json.ok) { + Vibration.vibrate(18); + } else { + Vibration.vibrate([0, 24, 36, 24]); + } + setResultOpen(true); + } finally { + setLoading(false); + } }; - void applyNavStyle(); - }, [colorScheme]); - const runScan = async () => { - setLoading(true); - try { - if (!cameraRef.current?.takePictureAsync) { - setResult("Camera capture is not ready yet."); - return; - } - const captureStart = Date.now(); - const shot = await cameraRef.current.takePictureAsync({ - base64: true, - quality: 0.9, - shutterSound: false, - }); - const captureElapsed = Date.now() - captureStart; - setCaptureMs(captureElapsed); - if (!shot?.base64) { - setResult("Failed to capture image from camera."); - return; - } - if (debugEnabled) { - setLastCaptureDataUri(`data:image/jpeg;base64,${shot.base64}`); - } - const scanStart = Date.now(); - const json = await scannerAdapter.scanStill({ - mode, - verifyKeyHex: verifyKeyHex || undefined, - verifyKeyId: 1, - imageBase64: shot.base64, - roiX: GUIDE_ROI.x, - roiY: GUIDE_ROI.y, - roiW: GUIDE_ROI.w, - roiH: GUIDE_ROI.h, - }); - const scanElapsed = Date.now() - scanStart; - setScanMs(scanElapsed); - setResult(JSON.stringify(json, null, 2)); - if (json.ok) { - Vibration.vibrate(18); - } else { - Vibration.vibrate([0, 24, 36, 24]); - } - setResultOpen(true); - } finally { - setLoading(false); - } - }; + const parsedResult = useMemo(() => { + try { + return JSON.parse(result) as { + ok?: boolean; + payload_utf8_lossy?: string; + payload_len?: number; + error?: string; + mode?: string; + }; + } catch { + return null; + } + }, [result]); - const parsedResult = useMemo(() => { - try { - return JSON.parse(result) as { - ok?: boolean; - payload_utf8_lossy?: string; - payload_len?: number; - error?: string; - mode?: string; - }; - } catch { - return null; - } - }, [result]); + const saveDebugBundle = async () => { + setDebugSaveMessage(null); + if (!lastCaptureUri || !lastCaptureWidth || !lastCaptureHeight) { + setDebugSaveMessage("No captured image available yet."); + return; + } + try { + const dir = getWritableBaseDir(); + if (!dir) { + setDebugSaveMessage("No writable app directory available."); + return; + } + const stamp = Date.now(); + const outDir = `${dir}glyphnet-debug-${stamp}/`; + await FileSystem.makeDirectoryAsync(outDir, { + intermediates: true, + }); - return ( - - {permission?.granted ? ( - - - - - - GlyphNet - - - - - - - Align the GlyphNet code inside the blue ribbon frame - - - {MODES.map((candidate) => ( - setMode(candidate)} - className={`flex-1 rounded-xl border px-3 py-2 ${ - mode === candidate - ? "border-sky-400 bg-sky-500/30" - : "border-slate-400/60 bg-slate-900/40" - }`} - > - - {candidate} - - - ))} - - - setTorchEnabled((v) => !v)} - className={`flex-1 items-center rounded-xl border px-3 py-2 ${ - torchEnabled - ? "border-amber-300 bg-amber-500/30" - : "border-slate-400/60 bg-slate-900/40" - }`} - > - - Torch: {torchEnabled ? "On" : "Off"} - - - - setDebugEnabled((v) => !v)} - className={`mt-3 items-center rounded-xl border px-3 py-2 ${ - debugEnabled - ? "border-amber-300 bg-amber-500/25" - : "border-slate-400/60 bg-slate-900/40" - }`} - > - - Debug: {debugEnabled ? "On" : "Off"} - - - - - - {loading ? "Scanning..." : "Scan Still"} - - - - - - ) : ( - - - {statusLabel} - - - Allow Camera - - - )} + const captureOut = `${outDir}capture.jpg`; + await FileSystem.copyAsync({ + from: lastCaptureUri, + to: captureOut, + }); - setResultOpen(false)} - > - - - - Scan Result - - {parsedResult ? ( - - - - {parsedResult.ok ? "Scan succeeded" : "Scan failed"} - - {!!parsedResult.mode && ( - Mode: {parsedResult.mode} - )} - - Capture: {captureMs ?? "-"} ms | Scan: {scanMs ?? "-"} ms - - + const crop = { + originX: Math.max( + 0, + Math.floor(lastCaptureWidth * GUIDE_ROI.x), + ), + originY: Math.max( + 0, + Math.floor(lastCaptureHeight * GUIDE_ROI.y), + ), + width: Math.max(1, Math.floor(lastCaptureWidth * GUIDE_ROI.w)), + height: Math.max( + 1, + Math.floor(lastCaptureHeight * GUIDE_ROI.h), + ), + }; + const cropped = await ImageManipulator.manipulateAsync( + lastCaptureUri, + [{ crop }], + { compress: 1, format: ImageManipulator.SaveFormat.JPEG }, + ); + const roiOut = `${outDir}roi.jpg`; + await FileSystem.copyAsync({ from: cropped.uri, to: roiOut }); - {parsedResult.ok ? ( - - Payload - - {parsedResult.payload_utf8_lossy || "(empty)"} - - - {parsedResult.payload_len ?? 0} bytes - - - ) : ( - - Error - - {parsedResult.error || "Unknown error"} - - - )} + const debugJson = { + ts: stamp, + mode, + roi: GUIDE_ROI, + capture: { + width: lastCaptureWidth, + height: lastCaptureHeight, + captureMs, + scanMs, + }, + result: parsedResult ?? result, + }; + const jsonOut = `${outDir}result.json`; + await FileSystem.writeAsStringAsync( + jsonOut, + JSON.stringify(debugJson, null, 2), + { + encoding: FileSystem.EncodingType.UTF8, + }, + ); - {debugEnabled && lastCaptureDataUri ? ( - - - Debug Capture + ROI - - - - + {permission?.granted ? ( + + + + /> + + + GlyphNet + - - ) : null} - - - {result} - - + + + + + Align the GlyphNet code inside the blue ribbon + frame + + + {MODES.map((candidate) => ( + setMode(candidate)} + className={`flex-1 rounded-xl border px-3 py-2 ${ + mode === candidate + ? "border-sky-400 bg-sky-500/30" + : "border-slate-400/60 bg-slate-900/40" + }`} + > + + {candidate} + + + ))} + + + setTorchEnabled((v) => !v)} + className={`flex-1 items-center rounded-xl border px-3 py-2 ${ + torchEnabled + ? "border-amber-300 bg-amber-500/30" + : "border-slate-400/60 bg-slate-900/40" + }`} + > + + Torch: {torchEnabled ? "On" : "Off"} + + + + setDebugEnabled((v) => !v)} + className={`mt-3 items-center rounded-xl border px-3 py-2 ${ + debugEnabled + ? "border-amber-300 bg-amber-500/25" + : "border-slate-400/60 bg-slate-900/40" + }`} + > + + Debug: {debugEnabled ? "On" : "Off"} + + + + + + {loading ? "Scanning..." : "Scan Still"} + + + + + ) : ( - - {result} - + + + {statusLabel} + + + + Allow Camera + + + )} - - setResultOpen(false)} - className="flex-1 items-center rounded-xl border border-slate-500 px-4 py-3" - > - Close - - { - setResultOpen(false); - void runScan(); - }} - className="flex-1 items-center rounded-xl bg-sky-600 px-4 py-3" - > - Scan Again - - - + setResultOpen(false)} + > + + + + Scan Result + + {parsedResult ? ( + + + + {parsedResult.ok + ? "Scan succeeded" + : "Scan failed"} + + {!!parsedResult.mode && ( + + Mode: {parsedResult.mode} + + )} + + Capture: {captureMs ?? "-"} ms | Scan:{" "} + {scanMs ?? "-"} ms + + + + {parsedResult.ok ? ( + + + Payload + + + {parsedResult.payload_utf8_lossy || + "(empty)"} + + + {parsedResult.payload_len ?? 0}{" "} + bytes + + + ) : ( + + + Error + + + {parsedResult.error || + "Unknown error"} + + + )} + + {debugEnabled && lastCaptureDataUri ? ( + + + Debug Capture + ROI + + + + + + { + void saveDebugBundle(); + }} + className="mt-3 items-center rounded-xl border border-cyan-400/50 bg-cyan-500/20 px-3 py-2" + > + + Save Debug Bundle + + + {debugSaveMessage ? ( + + {debugSaveMessage} + + ) : null} + + ) : null} + + + {result} + + + ) : ( + + {result} + + )} + + + setResultOpen(false)} + className="flex-1 items-center rounded-xl border border-slate-500 px-4 py-3" + > + + Close + + + { + setResultOpen(false); + void runScan(); + }} + className="flex-1 items-center rounded-xl bg-sky-600 px-4 py-3" + > + + Scan Again + + + + + + - - - ); + ); }