diff --git a/packages/mobile/.env.sample b/packages/mobile/.env.sample index 1e6ae1f1ac..9c4794f988 100644 --- a/packages/mobile/.env.sample +++ b/packages/mobile/.env.sample @@ -1,2 +1,3 @@ # Debug -EXPO_PUBLIC_OSMOSIS_ADDRESS= \ No newline at end of file +EXPO_PUBLIC_OSMOSIS_ADDRESS= +EXPO_PUBLIC_OSMOSIS_BE_BASE_URL="http://localhost:3000" \ No newline at end of file diff --git a/packages/mobile/app/_layout.tsx b/packages/mobile/app/_layout.tsx index cb6a3907ca..ff741a6b65 100644 --- a/packages/mobile/app/_layout.tsx +++ b/packages/mobile/app/_layout.tsx @@ -1,6 +1,10 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { superjson } from "@osmosis-labs/server"; -import { localLink } from "@osmosis-labs/trpc"; +import { localLink, makeSkipBatchLink } from "@osmosis-labs/trpc"; +import { + constructEdgeRouterKey, + constructEdgeUrlPathname, +} from "@osmosis-labs/utils"; import { ThemeProvider } from "@react-navigation/native"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -97,6 +101,7 @@ export default function RootLayout() { (opts.direction === "down" && opts.result instanceof Error), }), (runtime) => { + const removeLastSlash = (url: string) => url.replace(/\/$/, ""); const servers = { local: localLink({ router: appRouter, @@ -115,9 +120,38 @@ export default function RootLayout() { }, opentelemetryServiceName: "osmosis-mobile", })(runtime), + [constructEdgeRouterKey("main")]: makeSkipBatchLink( + removeLastSlash( + process.env.EXPO_PUBLIC_OSMOSIS_BE_BASE_URL ?? "" + ) + + "/" + + constructEdgeUrlPathname("main") + )(runtime), }; - return (ctx) => servers["local"](ctx); + return (ctx) => { + const { op } = ctx; + const pathParts = op.path.split("."); + const basePath = pathParts.shift() as string | "osmosisFe"; + + /** + * Combine the rest of the parts of the paths. This is what we're actually calling on the edge server. + * E.g. It will convert `edge.pools.getPool` to `pools.getPool` + */ + const possibleOsmosisFePath = pathParts.join("."); + + if (basePath === "osmosisFe") { + return servers[constructEdgeRouterKey("main")]({ + ...ctx, + op: { + ...op, + path: possibleOsmosisFePath, + }, + }); + } + + return servers[basePath](ctx); + }; }, ], }) diff --git a/packages/mobile/app/onboarding/camera-scan.tsx b/packages/mobile/app/onboarding/camera-scan.tsx index 6ee1e97342..4f65546499 100644 --- a/packages/mobile/app/onboarding/camera-scan.tsx +++ b/packages/mobile/app/onboarding/camera-scan.tsx @@ -1,21 +1,177 @@ +import { STUN_SERVER } from "@osmosis-labs/utils"; import MaskedView from "@react-native-masked-view/masked-view"; -import { CameraView, FocusMode } from "expo-camera"; -import { useState } from "react"; -import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native"; +import { BarcodeScanningResult, CameraView, FocusMode } from "expo-camera"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Dimensions, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { RTCIceCandidate, RTCPeerConnection } from "react-native-webrtc"; import { RouteHeader } from "~/components/route-header"; +import { Button } from "~/components/ui/button"; import { Text } from "~/components/ui/text"; import { Colors } from "~/constants/theme-colors"; +import { api } from "~/utils/trpc"; -const SCAN_ICON_RADIUS_RATIO = 0.05; -const SCAN_ICON_MASK_OFFSET_RATIO = 0.02; // used for mask to match spacing in CameraScan SVG const CAMERA_ASPECT_RATIO = 4 / 3; const SCAN_ICON_WIDTH_RATIO = 0.7; +// Define a type for the statuses +type WebRTCStatus = + | "Init" + | "FetchingOffer" + | "NoOffer" + | "CreatingPC" + | "ChannelOpen" + | "CreatingAnswer" + | "PostingAnswer" + | "AwaitingConnection" + | "Error"; + +const useWebRTC = () => { + const [status, setStatus] = useState("Init"); + const [connected, setConnected] = useState(false); + + const apiUtils = api.useUtils(); + const postCandidateMutation = + api.osmosisFe.webRTC.postCandidate.useMutation(); + const postAnswerMutation = api.osmosisFe.webRTC.postAnswer.useMutation(); + + // Keep a reference to the peer connection + const peerConnectionRef = useRef(null); + const intervalIdRef = useRef(null); + + const handleSession = useCallback( + async (sessionToken: string) => { + try { + setStatus("FetchingOffer"); + // 1) Fetch the offer + const offerRes = await apiUtils.osmosisFe.webRTC.fetchOffer.ensureData({ + sessionToken, + }); + if (!offerRes.offerSDP) { + setStatus("NoOffer"); + return; + } + + setStatus("CreatingPC"); + // 2) Create peer connection + const pc = new RTCPeerConnection({ iceServers: [STUN_SERVER] }); + peerConnectionRef.current = pc; + + // 3) When we get local ICE candidates, post them to the server + pc.onicecandidate = async (event) => { + if (event.candidate) { + await postCandidateMutation.mutate({ + sessionToken, + candidate: JSON.stringify(event.candidate), + }); + } + }; + + // 4) If the desktop created a data channel, handle it + pc.ondatachannel = (ev) => { + const channel = ev.channel; + channel.onopen = () => { + console.log("[Mobile] Data channel open, can receive data."); + setStatus("ChannelOpen"); + setConnected(true); + }; + channel.onmessage = (msgEvent) => { + console.log("[Mobile] Received data:", msgEvent.data); + // If the desktop sends the private key, you'd handle it here + }; + }; + + // 5) Set remote description (the desktop’s offer) + await pc.setRemoteDescription({ + type: "offer", + sdp: offerRes.offerSDP, + }); + + setStatus("CreatingAnswer"); + // 6) Create answer + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + setStatus("PostingAnswer"); + await postAnswerMutation.mutate({ + sessionToken, + answerSDP: answer.sdp ?? "", + }); + + // 7) Start polling for desktop ICE candidates + const intervalId = setInterval(async () => { + const candRes = + await apiUtils.osmosisFe.webRTC.fetchCandidates.ensureData({ + sessionToken, + }); + const candidates = candRes.candidates || []; + for (const cStr of candidates) { + try { + const candidate = new RTCIceCandidate(JSON.parse(cStr)); + await pc.addIceCandidate(candidate); + } catch (err) { + console.warn("[Mobile] Failed to add ICE candidate:", err); + } + } + }, 3000); + intervalIdRef.current = intervalId; + setStatus("AwaitingConnection"); + } catch (err: any) { + console.error(err); + setStatus("Error"); + } + }, + [ + apiUtils.osmosisFe.webRTC.fetchCandidates, + apiUtils.osmosisFe.webRTC.fetchOffer, + postAnswerMutation, + postCandidateMutation, + ] + ); + + useEffect(() => { + return () => { + peerConnectionRef.current?.close(); + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + intervalIdRef.current = null; + } + }; + }, []); + + const handleSendTest = () => { + // If we created our own data channel, you could send from here + // Or if the desktop created the channel, we handle ondatachannel + // This is optional, depending on your flow + const pc = peerConnectionRef.current; + if (!pc) return; + const sendChannel = pc.createDataChannel("mobileChannel"); + sendChannel.onopen = () => { + sendChannel.send("Hello from Mobile!"); + }; + }; + + return { + status, + handleSendTest, + handleSession, + connected, + }; +}; + export default function Welcome() { const { top, bottom } = useSafeAreaInsets(); const [autoFocus, setAutoFocus] = useState("off"); + const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false); + + const { status, handleSendTest, handleSession, connected } = useWebRTC(); const resetCameraAutoFocus = () => { const abortController = new AbortController(); @@ -28,9 +184,19 @@ export default function Welcome() { return () => abortController.abort(); }; + const onScanCode = async ({ data }: BarcodeScanningResult) => { + if (shouldFreezeCamera) { + return; + } + + setShouldFreezeCamera(true); + const parsedData = JSON.parse(data); + await handleSession(parsedData.sessionToken); + setShouldFreezeCamera(false); + }; + const overlayWidth = Dimensions.get("window").height / CAMERA_ASPECT_RATIO; const cameraWidth = Dimensions.get("window").width; - const cameraHeight = CAMERA_ASPECT_RATIO * cameraWidth; const scannerSize = Math.min(overlayWidth, cameraWidth) * SCAN_ICON_WIDTH_RATIO; @@ -75,51 +241,71 @@ export default function Welcome() { position: "relative", }} > - + {!shouldFreezeCamera && ( + + )} } > { - console.log(data); - }} + onBarcodeScanned={onScanCode} autofocus={autoFocus} /> - - - - - - + {!shouldFreezeCamera && ( + + + + + + + - + )} + {shouldFreezeCamera && ( + + + Loading... + + )} + {connected && ( +