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
32 changes: 27 additions & 5 deletions apps/mobile/app/login-scanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -21,6 +22,7 @@ export default function LoginScannerScreen() {
const theme = colorScheme ?? "light"
const [torch, setTorch] = React.useState<boolean>(false)
const [claiming, setClaiming] = React.useState(false)
const [importing, setImporting] = React.useState(false)
const navigated = React.useRef(false)
const login = useAuth((s) => s.login)

Expand All @@ -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)

Expand All @@ -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}`, {
Expand Down Expand Up @@ -134,20 +150,26 @@ export default function LoginScannerScreen() {
</View>

<View className="flex-1 items-center justify-center px-6">
{claiming ? (
{claiming || importing ? (
<View className="items-center gap-4">
<ActivityIndicator size="large" color={THEME[theme].primary} />
<Text className="text-sm text-muted-foreground">Logging in...</Text>
<Text className="text-sm text-muted-foreground">
{claiming ? "Logging in..." : "Reading QR code..."}
</Text>
</View>
) : (
<QrScanner onScan={handleScan} torch={torch} />
)}
</View>

<View className="items-center py-6">
<View className="flex-row items-center justify-center gap-4 py-6">
<Toggle pressed={torch} onPressedChange={setTorch}>
{torch ? <FlashlightIcon size={20} color={THEME[theme].foreground} /> : <FlashlightOffIcon size={20} color={THEME[theme].foreground} />}
</Toggle>
<Button variant="outline" onPress={handleGalleryImport} disabled={claiming || importing}>
<Images size={18} color={THEME[theme].foreground} />
<Text>Import from gallery</Text>
</Button>
</View>
</View>
)
Expand Down
33 changes: 28 additions & 5 deletions apps/mobile/app/new-connection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,6 +26,7 @@ export default function NewConnectionScreen() {
const theme = colorScheme ?? "light"
const [torch, setTorch] = React.useState<boolean>(false)
const [claiming, setClaiming] = React.useState(false)
const [importing, setImporting] = React.useState(false)
const navigated = React.useRef(false)
const login = useAuth((s) => s.login)

Expand All @@ -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)

Expand Down Expand Up @@ -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}`, {
Expand Down Expand Up @@ -201,20 +218,26 @@ export default function NewConnectionScreen() {
</View>

<View className="flex-1 items-center justify-center px-6">
{claiming ? (
{claiming || importing ? (
<View className="items-center gap-4">
<ActivityIndicator size="large" color={THEME[theme].primary} />
<Text className="text-sm text-muted-foreground">Linking device...</Text>
<Text className="text-sm text-muted-foreground">
{claiming ? "Linking device..." : "Reading QR code..."}
</Text>
</View>
) : (
<QrScanner onScan={handleScan} torch={torch} />
)}
</View>

<View className="items-center py-6">
<View className="flex-row items-center justify-center gap-4 py-6">
<Toggle pressed={torch} onPressedChange={setTorch}>
{torch ? <FlashlightIcon size={20} color={THEME[theme].foreground} /> : <FlashlightOffIcon size={20} color={THEME[theme].foreground} />}
</Toggle>
<Button variant="outline" onPress={handleGalleryImport} disabled={claiming || importing}>
<Images size={18} color={THEME[theme].foreground} />
<Text>Import from gallery</Text>
</Button>
</View>
</View>
)
Expand Down
11 changes: 7 additions & 4 deletions apps/mobile/app/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,15 @@ export default function OnboardingScreen() {

return (
<View className="flex-1 items-center justify-center px-8" style={{ width }}>
<View className={`mb-10 h-40 w-40 items-center justify-center rounded-[40px] ${index === 0 ? "" : item.accent}`}>
<View
className={`mb-10 items-center justify-center overflow-hidden rounded-[40px] ${index === 0 ? "bg-background" : `h-40 w-40 ${item.accent}`}`}
style={index === 0 ? { width: width - 32, height: width - 32 } : undefined}
>
{index === 0 ? (
<Image
source={theme === "dark" ? require("@/assets/branding-dark-mode.png") : require("@/assets/branding-light-mode.png")}
accessibilityLabel="CrossCode"
className="h-24 w-24"
source={theme === "dark" ? require("@/assets/onboarding_1_dark.png") : require("@/assets/onboarding_1_light.png")}
accessibilityLabel="Welcome to CrossCode"
style={{ width: "100%", height: "100%" }}
resizeMode="contain"
/>
) : Icon ? (
Expand Down
Binary file added apps/mobile/assets/onboarding_1_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/onboarding_1_light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions apps/mobile/lib/qr-from-gallery.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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
}
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions pnpm-lock.yaml

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

Loading