diff --git a/apps/mobile/app/login-scanner.tsx b/apps/mobile/app/login-scanner.tsx index 8139908..91d6383 100644 --- a/apps/mobile/app/login-scanner.tsx +++ b/apps/mobile/app/login-scanner.tsx @@ -6,7 +6,8 @@ import { decodeDeviceLinkQrPayload, detectQrPayloadType } from "@crosscode/share import QrScanner from "@/components/qr-scanner" import { Text } from "@/components/ui/text" import { Button } from "@/components/ui/button" -import { FlashlightIcon, FlashlightOffIcon, ArrowLeft } from "lucide-react-native" +import { importQrFromGallery } from "@/lib/qr-from-gallery" +import { FlashlightIcon, FlashlightOffIcon, ArrowLeft, Images } from "lucide-react-native" import { Toggle } from "@/components/ui/toggle" import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" @@ -21,6 +22,7 @@ export default function LoginScannerScreen() { const theme = colorScheme ?? "light" const [torch, setTorch] = React.useState(false) const [claiming, setClaiming] = React.useState(false) + const [importing, setImporting] = React.useState(false) const navigated = React.useRef(false) const login = useAuth((s) => s.login) @@ -31,7 +33,7 @@ export default function LoginScannerScreen() { ) const handleScan = async (data: string) => { - if (navigated.current || claiming) return + if (navigated.current || claiming || importing) return try { const payloadType = detectQrPayloadType(data) @@ -48,6 +50,20 @@ export default function LoginScannerScreen() { } } + const handleGalleryImport = async () => { + if (navigated.current || claiming || importing) return + + setImporting(true) + try { + const data = await importQrFromGallery() + if (data) await handleScan(data) + } catch (error) { + Alert.alert("Unable to import QR", error instanceof Error ? error.message : "Please select a valid QR code image") + } finally { + setImporting(false) + } + } + const claimDevice = async (token: string, serverUrl: string) => { try { const res = await fetch(`${serverUrl}/api/auth/device-link/claim?token=${token}`, { @@ -134,20 +150,26 @@ export default function LoginScannerScreen() { - {claiming ? ( + {claiming || importing ? ( - Logging in... + + {claiming ? "Logging in..." : "Reading QR code..."} + ) : ( )} - + {torch ? : } + ) diff --git a/apps/mobile/app/new-connection.tsx b/apps/mobile/app/new-connection.tsx index ac3c203..e8f0b87 100644 --- a/apps/mobile/app/new-connection.tsx +++ b/apps/mobile/app/new-connection.tsx @@ -5,7 +5,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context" import { decodeQrPayload, decodeLoginQrPayload, decodeDeviceLinkQrPayload, detectQrPayloadType } from "@crosscode/shared" import QrScanner from "@/components/qr-scanner" import { Text } from "@/components/ui/text" -import { FlashlightIcon, FlashlightOffIcon } from "lucide-react-native" +import { Button } from "@/components/ui/button" +import { importQrFromGallery } from "@/lib/qr-from-gallery" +import { FlashlightIcon, FlashlightOffIcon, Images } from "lucide-react-native" import { Toggle } from "@/components/ui/toggle" import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" @@ -24,6 +26,7 @@ export default function NewConnectionScreen() { const theme = colorScheme ?? "light" const [torch, setTorch] = React.useState(false) const [claiming, setClaiming] = React.useState(false) + const [importing, setImporting] = React.useState(false) const navigated = React.useRef(false) const login = useAuth((s) => s.login) @@ -34,7 +37,7 @@ export default function NewConnectionScreen() { ) const handleScan = async (data: string) => { - if (navigated.current || claiming) return + if (navigated.current || claiming || importing) return try { const payloadType = detectQrPayloadType(data) @@ -68,6 +71,20 @@ export default function NewConnectionScreen() { } } + const handleGalleryImport = async () => { + if (navigated.current || claiming || importing) return + + setImporting(true) + try { + const data = await importQrFromGallery() + if (data) await handleScan(data) + } catch (error) { + Alert.alert("Unable to import QR", error instanceof Error ? error.message : "Please select a valid QR code image") + } finally { + setImporting(false) + } + } + const claimDevice = async (token: string, serverUrl: string) => { try { const res = await fetch(`${serverUrl}/api/auth/device-link/claim?token=${token}`, { @@ -201,20 +218,26 @@ export default function NewConnectionScreen() { - {claiming ? ( + {claiming || importing ? ( - Linking device... + + {claiming ? "Linking device..." : "Reading QR code..."} + ) : ( )} - + {torch ? : } + ) diff --git a/apps/mobile/app/onboarding.tsx b/apps/mobile/app/onboarding.tsx index 517c5c7..65cfb67 100644 --- a/apps/mobile/app/onboarding.tsx +++ b/apps/mobile/app/onboarding.tsx @@ -112,12 +112,15 @@ export default function OnboardingScreen() { return ( - + {index === 0 ? ( ) : Icon ? ( diff --git a/apps/mobile/assets/onboarding_1_dark.png b/apps/mobile/assets/onboarding_1_dark.png new file mode 100644 index 0000000..534a6be Binary files /dev/null and b/apps/mobile/assets/onboarding_1_dark.png differ diff --git a/apps/mobile/assets/onboarding_1_light.png b/apps/mobile/assets/onboarding_1_light.png new file mode 100644 index 0000000..b8f5d39 Binary files /dev/null and b/apps/mobile/assets/onboarding_1_light.png differ diff --git a/apps/mobile/lib/qr-from-gallery.ts b/apps/mobile/lib/qr-from-gallery.ts new file mode 100644 index 0000000..ccb186d --- /dev/null +++ b/apps/mobile/lib/qr-from-gallery.ts @@ -0,0 +1,75 @@ +import * as ImagePicker from "expo-image-picker" +import { ImageManipulator, SaveFormat } from "expo-image-manipulator" +import jsQR from "jsqr" +import jpeg from "jpeg-js" + +const MAX_IMAGE_SIZE = 1024 +const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +function base64ToBytes(value: string): Uint8Array { + const cleanValue = value.replace(/[^A-Za-z0-9+/]/g, "") + const bytes = new Uint8Array(Math.floor((cleanValue.length * 3) / 4)) + + let byteIndex = 0 + for (let index = 0; index < cleanValue.length; index += 4) { + const first = BASE64_CHARS.indexOf(cleanValue[index]) + const second = BASE64_CHARS.indexOf(cleanValue[index + 1]) + const third = BASE64_CHARS.indexOf(cleanValue[index + 2]) + const fourth = BASE64_CHARS.indexOf(cleanValue[index + 3]) + + bytes[byteIndex++] = (first << 2) | (second >> 4) + if (third >= 0 && byteIndex < bytes.length) { + bytes[byteIndex++] = ((second & 15) << 4) | (third >> 2) + } + if (fourth >= 0 && byteIndex < bytes.length) { + bytes[byteIndex++] = ((third & 3) << 6) | fourth + } + } + + return bytes +} + +export async function importQrFromGallery(): Promise { + const pickerResult = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: false, + quality: 1, + }) + + if (pickerResult.canceled) return null + + const asset = pickerResult.assets[0] + if (!asset) throw new Error("No image selected") + + const manipulator = ImageManipulator.manipulate(asset.uri) + const maxDimension = Math.max(asset.width, asset.height) + + if (maxDimension > MAX_IMAGE_SIZE) { + if (asset.width >= asset.height) { + manipulator.resize({ width: MAX_IMAGE_SIZE }) + } else { + manipulator.resize({ height: MAX_IMAGE_SIZE }) + } + } + + const renderedImage = await manipulator.renderAsync() + const imageResult = await renderedImage.saveAsync({ + base64: true, + compress: 1, + format: SaveFormat.JPEG, + }) + + if (!imageResult.base64) throw new Error("Unable to read selected image") + + const decodedImage = jpeg.decode(base64ToBytes(imageResult.base64), { + useTArray: true, + }) + const qrCode = jsQR( + new Uint8ClampedArray(decodedImage.data), + decodedImage.width, + decodedImage.height, + ) + + if (!qrCode) throw new Error("No QR code found in selected image") + return qrCode.data +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5181b90..2c71192 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "expo-constants": "^57.0.9", "expo-font": "~57.0.1", "expo-image": "~57.0.2", + "expo-image-manipulator": "~57.0.10", "expo-image-picker": "^57.0.8", "expo-linking": "^57.0.5", "expo-notifications": "~57.0.10", @@ -34,6 +35,8 @@ "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", "fast-text-encoding": "^1.0.6", + "jpeg-js": "^0.4.4", + "jsqr": "^1.4.0", "lucide-react-native": "^0.487.0", "nativewind": "^4.2.6", "react": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4314fa0..62ed772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: expo-image: specifier: ~57.0.2 version: 57.0.2(expo@57.0.11)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) + expo-image-manipulator: + specifier: ~57.0.10 + version: 57.0.10(expo@57.0.11) expo-image-picker: specifier: ^57.0.8 version: 57.0.8(expo@57.0.11) @@ -113,6 +116,12 @@ importers: fast-text-encoding: specifier: ^1.0.6 version: 1.0.6 + jpeg-js: + specifier: ^0.4.4 + version: 0.4.4 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 lucide-react-native: specifier: ^0.487.0 version: 0.487.0(react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) @@ -5126,6 +5135,11 @@ packages: peerDependencies: expo: '*' + expo-image-manipulator@57.0.10: + resolution: {integrity: sha512-KTxf31ozEJnnCHxpMz6Illg8SIswUyPgex4IT/YTCH8yvvBFw1Tc7YE90Tik1103gMUWn5crXq2om6iw1RLQHQ==} + peerDependencies: + expo: '*' + expo-image-picker@57.0.8: resolution: {integrity: sha512-LXJCGxunnQ4beqk+TW3+llRyss/XITOKWdIjqgF1H9mL3CgKN+hvABWAqLVWapGz7RoJBf+r53FO9i48yicbbw==} peerDependencies: @@ -5829,6 +5843,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5873,6 +5890,9 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -13060,6 +13080,11 @@ snapshots: dependencies: expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-image-manipulator@57.0.10(expo@57.0.11): + dependencies: + expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-image-loader: 57.0.1(expo@57.0.11) + expo-image-picker@57.0.8(expo@57.0.11): dependencies: expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -13860,6 +13885,8 @@ snapshots: joycon@3.1.1: {} + jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} js-yaml@3.15.0: @@ -13895,6 +13922,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsqr@1.4.0: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9